@openreplay/tracker 18.0.17 → 18.1.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/dist/cjs/index.js CHANGED
@@ -78,7 +78,7 @@ class ConditionsManager {
78
78
  return resultCondition;
79
79
  }
80
80
  };
81
- this.durationInt = null;
81
+ this.durationInts = [];
82
82
  }
83
83
  setConditions(conditions) {
84
84
  this.conditions = conditions;
@@ -171,18 +171,22 @@ class ConditionsManager {
171
171
  });
172
172
  }
173
173
  }
174
+ clearDurationInt(int) {
175
+ clearInterval(int);
176
+ this.durationInts = this.durationInts.filter((i) => i !== int);
177
+ }
174
178
  processDuration(durationMs, condName) {
175
- this.durationInt = setInterval(() => {
176
- const sessionLength = performance.now();
177
- if (sessionLength > durationMs) {
179
+ // Track each interval independently: with more than one duration condition a
180
+ // single shared field would leak every interval but the last, and the timer
181
+ // would keep firing after it triggered.
182
+ const int = setInterval(() => {
183
+ if (performance.now() > durationMs) {
184
+ this.clearDurationInt(int);
178
185
  this.trigger(condName);
179
186
  }
180
187
  }, 1000);
181
- this.app.attachStopCallback(() => {
182
- if (this.durationInt) {
183
- clearInterval(this.durationInt);
184
- }
185
- });
188
+ this.durationInts.push(int);
189
+ this.app.attachStopCallback(() => this.clearDurationInt(int));
186
190
  }
187
191
  networkRequest(message) {
188
192
  // method - 2, url - 3, status - 6, duration - 8
@@ -701,13 +705,18 @@ function throttleWithTrailing(fn, interval) {
701
705
  timeouts.delete(key);
702
706
  }
703
707
  lastCalls.set(key, now);
708
+ // args are consumed now; don't retain them keyed by every id we ever saw
709
+ lastArgs.delete(key);
704
710
  fn(key, ...args);
705
711
  }
706
712
  else if (!timeouts.has(key)) {
707
713
  const timeoutId = setTimeout(() => {
708
- lastCalls.set(key, Date.now());
709
714
  timeouts.delete(key);
710
715
  const finalArgs = lastArgs.get(key);
716
+ // Trailing call closes the throttle window for this key: drop its
717
+ // bookkeeping so the maps don't grow one permanent entry per key.
718
+ lastArgs.delete(key);
719
+ lastCalls.delete(key);
711
720
  fn(key, ...finalArgs);
712
721
  }, remaining);
713
722
  timeouts.set(key, timeoutId);
@@ -2954,10 +2963,18 @@ class Sanitizer {
2954
2963
  data = data.replace(/\d/g, '0');
2955
2964
  }
2956
2965
  if (this.options.obscureTextEmails) {
2957
- data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
2958
- const [name, domain] = email.split('@');
2959
- const [domainName, host] = domain.split('.');
2960
- return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
2966
+ // Match every email-like substring anywhere in the text (not just when the
2967
+ // whole node is a single email), and preserve every domain label including
2968
+ // multi-part TLDs (e.g. .co.uk) so nothing leaks through unmasked.
2969
+ data = data.replace(/[^\s@]+@[^\s@]+\.[^\s@]+/g, (email) => {
2970
+ const atIdx = email.lastIndexOf('@');
2971
+ const name = email.slice(0, atIdx);
2972
+ const domain = email.slice(atIdx + 1);
2973
+ const maskedDomain = domain
2974
+ .split('.')
2975
+ .map((part) => stars(part))
2976
+ .join('.');
2977
+ return `${stars(name)}@${maskedDomain}`;
2961
2978
  });
2962
2979
  }
2963
2980
  return data;
@@ -2994,8 +3011,14 @@ async function parseUseEl(useElement, mode, domParser) {
2994
3011
  console.warn('Openreplay: Invalid xlink:href or href found on <use>.');
2995
3012
  return;
2996
3013
  }
2997
- if (iconCache[symbolId]) {
2998
- return iconCache[symbolId];
3014
+ // Key the cache by url + symbol + mode: the same symbol id lives in different
3015
+ // sprite files, and different modes store different value shapes (object vs
3016
+ // string vs data-url), so keying by symbolId alone returns the wrong asset.
3017
+ // Namespace remote vs in-document sprites so a relative sprite file literally
3018
+ // named "local" can't collide with the in-document bucket.
3019
+ const cacheKey = url ? `url:${url}#${symbolId}#${mode}` : `doc:${symbolId}#${mode}`;
3020
+ if (iconCache[cacheKey]) {
3021
+ return iconCache[cacheKey];
2999
3022
  }
3000
3023
  // happens if svg spritemap is local, fastest case for us
3001
3024
  if (!url && symbolId) {
@@ -3014,7 +3037,7 @@ async function parseUseEl(useElement, mode, domParser) {
3014
3037
  ${symbol.innerHTML}
3015
3038
  </svg>
3016
3039
  `;
3017
- iconCache[symbolId] = inlineSvg;
3040
+ iconCache[cacheKey] = inlineSvg;
3018
3041
  return inlineSvg;
3019
3042
  }
3020
3043
  else {
@@ -3049,10 +3072,21 @@ async function parseUseEl(useElement, mode, domParser) {
3049
3072
  }
3050
3073
  else {
3051
3074
  svgUrlCache[url] = 1;
3052
- const response = await fetch(url);
3053
- const svgText = await response.text();
3054
- svgDoc = domParser.parseFromString(svgText, 'image/svg+xml');
3055
- svgUrlCache[url] = svgDoc;
3075
+ try {
3076
+ const response = await fetch(url);
3077
+ const svgText = await response.text();
3078
+ svgDoc = domParser.parseFromString(svgText, 'image/svg+xml');
3079
+ svgUrlCache[url] = svgDoc;
3080
+ }
3081
+ catch (e) {
3082
+ // Clear the in-flight sentinel so a failed fetch doesn't wedge every
3083
+ // future <use> for this url into a 10s poll-then-give-up loop.
3084
+ delete svgUrlCache[url];
3085
+ throw e;
3086
+ }
3087
+ }
3088
+ if (!svgDoc) {
3089
+ return;
3056
3090
  }
3057
3091
  // @ts-ignore
3058
3092
  const symbol = svgDoc.getElementById(symbolId);
@@ -3067,7 +3101,7 @@ async function parseUseEl(useElement, mode, domParser) {
3067
3101
  ${symbol.innerHTML}
3068
3102
  </svg>
3069
3103
  `;
3070
- iconCache[symbolId] = inlineSvg;
3104
+ iconCache[cacheKey] = inlineSvg;
3071
3105
  return inlineSvg;
3072
3106
  }
3073
3107
  if (mode === 'dataurl') ;
@@ -3253,6 +3287,7 @@ class Observer {
3253
3287
  }
3254
3288
  if (value === null) {
3255
3289
  this.app.send(RemoveNodeAttribute(id, name));
3290
+ return;
3256
3291
  }
3257
3292
  if (isUseElement(node) && name === 'href' && !this.disableSprites) {
3258
3293
  parseUseEl(node, 'svgtext', this.domParser)
@@ -4276,7 +4311,7 @@ class App {
4276
4311
  this.stopCallbacks = [];
4277
4312
  this.commitCallbacks = [];
4278
4313
  this.activityState = ActivityState.NotActive;
4279
- this.version = '18.0.17'; // TODO: version compatability check inside each plugin.
4314
+ this.version = '18.1.0'; // TODO: version compatability check inside each plugin.
4280
4315
  this.socketMode = false;
4281
4316
  this.compressionThreshold = 24 * 1000;
4282
4317
  this.bc = null;
@@ -4288,6 +4323,7 @@ class App {
4288
4323
  this.frameOderNumber = 0;
4289
4324
  this.frameLevel = 0;
4290
4325
  this.emptyBatchCounter = 0;
4326
+ this.lastHiddenAt = null;
4291
4327
  /** used by child iframes for crossdomain only */
4292
4328
  this.parentActive = false;
4293
4329
  this.checkStatus = () => {
@@ -4732,12 +4768,18 @@ class App {
4732
4768
  }
4733
4769
  };
4734
4770
  this.startTimeout = null;
4735
- this.restart = () => {
4771
+ this.restart = (forceNew = false) => {
4736
4772
  this.stop(false);
4737
4773
  this.waitStatus(ActivityState.NotActive).then(() => {
4738
4774
  this.allowAppStart();
4739
- this.start(this.prevOpts, true)
4775
+ // Force a brand-new session when asked (e.g. a stale bfcache restore) without
4776
+ // letting forceNew leak into later restarts through prevOpts.
4777
+ const startOpts = forceNew ? { ...this.prevOpts, forceNew: true } : this.prevOpts;
4778
+ this.start(startOpts, true)
4740
4779
  .then((r) => {
4780
+ if (forceNew) {
4781
+ this.prevOpts = { ...this.prevOpts, forceNew: false };
4782
+ }
4741
4783
  this.debug.info('Session restart', r);
4742
4784
  })
4743
4785
  .catch((e) => {
@@ -4745,6 +4787,34 @@ class App {
4745
4787
  });
4746
4788
  });
4747
4789
  };
4790
+ this.handlePageShow = (e) => {
4791
+ // Only back-forward cache restores set persisted === true; a normal navigation is false.
4792
+ if (!e || !e.persisted) {
4793
+ return;
4794
+ }
4795
+ // Don't resurrect a tracker that was intentionally stopped.
4796
+ if (this.activityState === ActivityState.NotActive) {
4797
+ return;
4798
+ }
4799
+ try {
4800
+ const frozenMs = this.lastHiddenAt != null ? Date.now() - this.lastHiddenAt : null;
4801
+ this.lastHiddenAt = null;
4802
+ const threshold = this.options.bfcacheRestartThreshold ?? 30 * 60 * 1000;
4803
+ // If we couldn't measure the freeze, or it outlasted the threshold, the session has
4804
+ // likely expired server-side - start a fresh one (forceNew). Otherwise let the
4805
+ // resumed page continue on the existing session (preserving continuity).
4806
+ if (frozenMs === null || frozenMs >= threshold) {
4807
+ this.debug.info('OpenReplay: restarting session after bfcache restore', { frozenMs });
4808
+ this.restart(true);
4809
+ }
4810
+ else {
4811
+ this.debug.info('OpenReplay: resuming session after bfcache restore', { frozenMs });
4812
+ }
4813
+ }
4814
+ catch (err) {
4815
+ this._debug('bfcache_restore', err);
4816
+ }
4817
+ };
4748
4818
  this.send = (message, urgent = false) => {
4749
4819
  if (this.activityState === ActivityState.NotActive) {
4750
4820
  return;
@@ -4872,6 +4942,7 @@ class App {
4872
4942
  localStorage: null,
4873
4943
  sessionStorage: null,
4874
4944
  forceSingleTab: false,
4945
+ bfcacheRestartThreshold: 30 * 60 * 1000,
4875
4946
  assistSocketHost: '',
4876
4947
  captureIFrames: true,
4877
4948
  obscureTextEmails: false,
@@ -5193,7 +5264,16 @@ class App {
5193
5264
  }
5194
5265
  };
5195
5266
  this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false);
5196
- this.attachEventListener(window, 'pagehide', alertWorker, false, false);
5267
+ this.attachEventListener(window, 'pagehide', () => {
5268
+ // pagehide is the entry point into the back-forward cache; remember when
5269
+ // it fired so pageshow can measure how long the page stayed frozen.
5270
+ this.lastHiddenAt = Date.now();
5271
+ alertWorker();
5272
+ }, false, false);
5273
+ // Detect restores from the back-forward cache. Registered through
5274
+ // attachEventListener so it's removed on stop (no leak) and a tracker that was
5275
+ // intentionally stopped does not resurrect itself on a back/forward restore.
5276
+ this.attachEventListener(window, 'pageshow', this.handlePageShow, false, false);
5197
5277
  // TODO: stop session after inactivity timeout (make configurable)
5198
5278
  this.attachEventListener(document, 'visibilitychange', (e) => document.visibilityState === 'hidden' && alertWorker(), false);
5199
5279
  }
@@ -5483,9 +5563,12 @@ class App {
5483
5563
  this.sessionStorage.removeItem(this.options.session_reset_key);
5484
5564
  }
5485
5565
  }
5566
+ getSessionVersionHash() {
5567
+ const PROTO_VERSION = '2';
5568
+ return `${PROTO_VERSION}x${this.version}`;
5569
+ }
5486
5570
  checkSessionToken(forceNew) {
5487
- const PROTO_VERSION = "2";
5488
- const versionHash = `${PROTO_VERSION}x${this.version}`;
5571
+ const versionHash = this.getSessionVersionHash();
5489
5572
  const needReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null;
5490
5573
  let needNewSessionID = forceNew || needReset;
5491
5574
  const sessionToken = this.session.getSessionToken(this.projectKey);
@@ -5816,6 +5899,7 @@ class App {
5816
5899
  }
5817
5900
  this.delay = delay;
5818
5901
  this.session.setSessionToken(token, this.projectKey);
5902
+ this.sessionStorage.setItem(`${this.options.session_token_key}_version`, this.getSessionVersionHash());
5819
5903
  if (sessionToken && sessionToken !== token) {
5820
5904
  this.bc?.postMessage({
5821
5905
  type: proto.reset,
@@ -6155,6 +6239,9 @@ function Console (app, opts) {
6155
6239
  };
6156
6240
  app.attachStartCallback(reset);
6157
6241
  app.ticker.attach(reset, 33, false);
6242
+ // Restorers for every method we patch, so we can put the host's console back
6243
+ // when the session stops instead of leaving it proxied for the page's lifetime.
6244
+ const restores = [];
6158
6245
  const patchConsole = (console, ctx) => {
6159
6246
  const handler = {
6160
6247
  apply: function (target, thisArg, argumentsList) {
@@ -6175,10 +6262,30 @@ function Console (app, opts) {
6175
6262
  }
6176
6263
  const fn = ctx.console[method];
6177
6264
  console[method] = new Proxy(fn, handler);
6265
+ restores.push(() => {
6266
+ console[method] = fn;
6267
+ });
6178
6268
  });
6179
6269
  };
6180
6270
  const patchContext = app.safe((context) => patchConsole(context.console, context));
6181
- patchContext(window);
6271
+ let patched = false;
6272
+ const startPatching = () => {
6273
+ if (patched) {
6274
+ return;
6275
+ }
6276
+ patched = true;
6277
+ patchContext(window);
6278
+ };
6279
+ // Patch immediately, and re-patch on any later start; restore on stop so the
6280
+ // host console isn't left wrapped for the page's lifetime and capture is
6281
+ // re-established symmetrically across a stop/restart.
6282
+ startPatching();
6283
+ app.attachStartCallback(startPatching);
6284
+ app.attachStopCallback(() => {
6285
+ patched = false;
6286
+ restores.forEach((restore) => restore());
6287
+ restores.length = 0;
6288
+ });
6182
6289
  app.observer.attachContextCallback(patchContext);
6183
6290
  }
6184
6291
 
@@ -6499,7 +6606,7 @@ function Img (app) {
6499
6606
  const target = mutation.target;
6500
6607
  const id = app.nodes.getID(target);
6501
6608
  if (id === undefined) {
6502
- return;
6609
+ continue;
6503
6610
  }
6504
6611
  if (mutation.attributeName === 'src') {
6505
6612
  sendSrc(id, target);
@@ -7439,15 +7546,12 @@ function CSSRules (app, opts) {
7439
7546
  if (!sheetID)
7440
7547
  continue;
7441
7548
  if (options.checkLimit) {
7442
- if (!checkIterations[sheetID]) {
7443
- checkIterations[sheetID] = 0;
7444
- }
7445
- else {
7446
- checkIterations[sheetID]++;
7447
- }
7549
+ checkIterations[sheetID] = (checkIterations[sheetID] ?? 0) + 1;
7448
7550
  if (checkIterations[sheetID] > options.checkLimit) {
7449
7551
  trackedSheets.delete(sheet);
7450
- return;
7552
+ // continue, not return: skipping one exhausted sheet must not
7553
+ // abandon the remaining tracked sheets in this scan.
7554
+ continue;
7451
7555
  }
7452
7556
  }
7453
7557
  for (let j = 0; j < sheet.cssRules.length; j++) {
@@ -9243,7 +9347,7 @@ function webAnimations(app, options = {}) {
9243
9347
  /**
9244
9348
  * Detects client browser, OS, and device information
9245
9349
  */
9246
- function uaParse(sWindow) {
9350
+ function uaParse(sWindow, onOsVersionUpdate) {
9247
9351
  const unknown = '-';
9248
9352
  // Screen detection
9249
9353
  let width = 0;
@@ -9402,7 +9506,11 @@ function uaParse(sWindow) {
9402
9506
  .getHighEntropyValues(['platformVersion'])
9403
9507
  .then((ua) => {
9404
9508
  const version = parseInt(ua.platformVersion.split('.')[0], 10);
9405
- osVersion = version < 13 ? '10' : '11';
9509
+ // uaParse has already returned by the time this resolves, so a local
9510
+ // assignment would be lost - notify the caller to update its state.
9511
+ const refined = version < 13 ? '10' : '11';
9512
+ osVersion = refined;
9513
+ onOsVersionUpdate?.(refined);
9406
9514
  })
9407
9515
  .catch(() => {
9408
9516
  // ignore errors and keep osVersion as is
@@ -9578,7 +9686,12 @@ class ConstantProperties {
9578
9686
  this.clearSuperProperties = () => {
9579
9687
  this.localStorage.setItem(superPropKey, JSON.stringify({}));
9580
9688
  };
9581
- const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } = uaParse(win);
9689
+ const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } = uaParse(win, (refinedOsVersion) => {
9690
+ // Windows 10/11 are indistinguishable from the UA string; the async
9691
+ // high-entropy result lands after construction, so update it live here.
9692
+ // `all` is a getter, so later events pick up the corrected value.
9693
+ this.osVersion = refinedOsVersion;
9694
+ });
9582
9695
  const storedUserId = this.sessionStorage.getItem(userIdKey);
9583
9696
  if (storedUserId) {
9584
9697
  this.user_id = storedUserId;
@@ -9611,7 +9724,7 @@ class ConstantProperties {
9611
9724
  user_id: this.user_id,
9612
9725
  distinct_id: this.deviceId,
9613
9726
  sdk_edition: 'web',
9614
- sdk_version: '18.0.17',
9727
+ sdk_version: '18.1.0',
9615
9728
  timezone: getUTCOffsetString(),
9616
9729
  search_engine: this.searchEngine,
9617
9730
  };
@@ -9961,7 +10074,15 @@ class Batcher {
9961
10074
  this.stopped = false;
9962
10075
  this.paused = false;
9963
10076
  this.onVisibilityChange = () => {
9964
- this.paused = document.hidden;
10077
+ if (document.hidden) {
10078
+ // The tab may be closing; flush now with keepalive so buffered events
10079
+ // survive the transition instead of waiting for the (paused) interval.
10080
+ this.flush(true);
10081
+ this.paused = true;
10082
+ }
10083
+ else {
10084
+ this.paused = false;
10085
+ }
9965
10086
  };
9966
10087
  }
9967
10088
  getBatches() {
@@ -10046,8 +10167,9 @@ class Batcher {
10046
10167
  }
10047
10168
  return Array.from(uniqueEventsByType.values());
10048
10169
  }
10049
- sendBatch(batch) {
10170
+ sendBatch(batch, keepalive = false, onTerminalFailure) {
10050
10171
  if (this.stopped) {
10172
+ onTerminalFailure?.();
10051
10173
  return;
10052
10174
  }
10053
10175
  const sentBatch = batch;
@@ -10055,11 +10177,14 @@ class Batcher {
10055
10177
  const send = () => {
10056
10178
  const token = this.getToken();
10057
10179
  if (!token) {
10180
+ // No token yet: keep the events so they go out once one is available.
10181
+ onTerminalFailure?.();
10058
10182
  return;
10059
10183
  }
10060
10184
  attempts++;
10061
10185
  return fetch(`${this.backendUrl}${this.apiEdp}`, {
10062
10186
  method: 'POST',
10187
+ keepalive,
10063
10188
  headers: {
10064
10189
  'Content-Type': 'application/json',
10065
10190
  Authorization: `Bearer ${token}`,
@@ -10078,6 +10203,7 @@ class Batcher {
10078
10203
  send();
10079
10204
  });
10080
10205
  }
10206
+ onTerminalFailure?.();
10081
10207
  return;
10082
10208
  }
10083
10209
  if (response.status === 403) {
@@ -10086,6 +10212,7 @@ class Batcher {
10086
10212
  send();
10087
10213
  });
10088
10214
  }
10215
+ onTerminalFailure?.();
10089
10216
  return;
10090
10217
  }
10091
10218
  if (!response.ok) {
@@ -10096,6 +10223,9 @@ class Batcher {
10096
10223
  if (attempts < this.retryLimit) {
10097
10224
  setTimeout(() => void send(), this.retryTimeout);
10098
10225
  }
10226
+ else {
10227
+ onTerminalFailure?.();
10228
+ }
10099
10229
  });
10100
10230
  };
10101
10231
  void send();
@@ -10112,16 +10242,38 @@ class Batcher {
10112
10242
  }
10113
10243
  }, this.autosendInterval);
10114
10244
  }
10115
- flush() {
10116
- const categories = Object.keys(this.batch);
10117
- const isEmpty = categories.every((category) => this.batch[category].length === 0);
10245
+ flush(keepalive = false) {
10246
+ const cats = Object.keys(this.batch);
10247
+ const isEmpty = cats.every((category) => this.batch[category].length === 0);
10118
10248
  if (isEmpty) {
10119
10249
  return;
10120
10250
  }
10121
- this.sendBatch(this.getBatches());
10122
- categories.forEach((key) => {
10251
+ const batches = this.getBatches();
10252
+ // Snapshot what we're about to send so we can restore it if the request
10253
+ // ultimately fails, instead of dropping events the moment we clear the buffer.
10254
+ const snapshot = {
10255
+ [categories.people]: [...batches.data[categories.people]],
10256
+ [categories.events]: [...batches.data[categories.events]],
10257
+ };
10258
+ cats.forEach((key) => {
10123
10259
  this.batch[key] = [];
10124
10260
  });
10261
+ this.sendBatch(batches, keepalive, () => this.requeue(snapshot));
10262
+ }
10263
+ requeue(snapshot) {
10264
+ if (this.stopped) {
10265
+ return;
10266
+ }
10267
+ // Put the failed events back at the front, ahead of anything queued since,
10268
+ // so they're retried on the next flush rather than lost.
10269
+ this.batch[categories.people] = [
10270
+ ...snapshot[categories.people],
10271
+ ...this.batch[categories.people],
10272
+ ];
10273
+ this.batch[categories.events] = [
10274
+ ...snapshot[categories.events],
10275
+ ...this.batch[categories.events],
10276
+ ];
10125
10277
  }
10126
10278
  stop() {
10127
10279
  this.flush();
@@ -10313,7 +10465,7 @@ class API {
10313
10465
  this.signalStartIssue = (reason, missingApi) => {
10314
10466
  const doNotTrack = this.checkDoNotTrack();
10315
10467
  console.log("Tracker couldn't start due to:", JSON.stringify({
10316
- trackerVersion: '18.0.17',
10468
+ trackerVersion: '18.1.0',
10317
10469
  projectKey: this.options.projectKey,
10318
10470
  doNotTrack,
10319
10471
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,