@openreplay/tracker 18.0.17-beta.0 → 18.1.0-beta.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/lib/index.js CHANGED
@@ -74,7 +74,7 @@ class ConditionsManager {
74
74
  return resultCondition;
75
75
  }
76
76
  };
77
- this.durationInt = null;
77
+ this.durationInts = [];
78
78
  }
79
79
  setConditions(conditions) {
80
80
  this.conditions = conditions;
@@ -167,18 +167,22 @@ class ConditionsManager {
167
167
  });
168
168
  }
169
169
  }
170
+ clearDurationInt(int) {
171
+ clearInterval(int);
172
+ this.durationInts = this.durationInts.filter((i) => i !== int);
173
+ }
170
174
  processDuration(durationMs, condName) {
171
- this.durationInt = setInterval(() => {
172
- const sessionLength = performance.now();
173
- if (sessionLength > durationMs) {
175
+ // Track each interval independently: with more than one duration condition a
176
+ // single shared field would leak every interval but the last, and the timer
177
+ // would keep firing after it triggered.
178
+ const int = setInterval(() => {
179
+ if (performance.now() > durationMs) {
180
+ this.clearDurationInt(int);
174
181
  this.trigger(condName);
175
182
  }
176
183
  }, 1000);
177
- this.app.attachStopCallback(() => {
178
- if (this.durationInt) {
179
- clearInterval(this.durationInt);
180
- }
181
- });
184
+ this.durationInts.push(int);
185
+ this.app.attachStopCallback(() => this.clearDurationInt(int));
182
186
  }
183
187
  networkRequest(message) {
184
188
  // method - 2, url - 3, status - 6, duration - 8
@@ -697,13 +701,18 @@ function throttleWithTrailing(fn, interval) {
697
701
  timeouts.delete(key);
698
702
  }
699
703
  lastCalls.set(key, now);
704
+ // args are consumed now; don't retain them keyed by every id we ever saw
705
+ lastArgs.delete(key);
700
706
  fn(key, ...args);
701
707
  }
702
708
  else if (!timeouts.has(key)) {
703
709
  const timeoutId = setTimeout(() => {
704
- lastCalls.set(key, Date.now());
705
710
  timeouts.delete(key);
706
711
  const finalArgs = lastArgs.get(key);
712
+ // Trailing call closes the throttle window for this key: drop its
713
+ // bookkeeping so the maps don't grow one permanent entry per key.
714
+ lastArgs.delete(key);
715
+ lastCalls.delete(key);
707
716
  fn(key, ...finalArgs);
708
717
  }, remaining);
709
718
  timeouts.set(key, timeoutId);
@@ -2950,10 +2959,18 @@ class Sanitizer {
2950
2959
  data = data.replace(/\d/g, '0');
2951
2960
  }
2952
2961
  if (this.options.obscureTextEmails) {
2953
- data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
2954
- const [name, domain] = email.split('@');
2955
- const [domainName, host] = domain.split('.');
2956
- return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
2962
+ // Match every email-like substring anywhere in the text (not just when the
2963
+ // whole node is a single email), and preserve every domain label including
2964
+ // multi-part TLDs (e.g. .co.uk) so nothing leaks through unmasked.
2965
+ data = data.replace(/[^\s@]+@[^\s@]+\.[^\s@]+/g, (email) => {
2966
+ const atIdx = email.lastIndexOf('@');
2967
+ const name = email.slice(0, atIdx);
2968
+ const domain = email.slice(atIdx + 1);
2969
+ const maskedDomain = domain
2970
+ .split('.')
2971
+ .map((part) => stars(part))
2972
+ .join('.');
2973
+ return `${stars(name)}@${maskedDomain}`;
2957
2974
  });
2958
2975
  }
2959
2976
  return data;
@@ -2990,8 +3007,14 @@ async function parseUseEl(useElement, mode, domParser) {
2990
3007
  console.warn('Openreplay: Invalid xlink:href or href found on <use>.');
2991
3008
  return;
2992
3009
  }
2993
- if (iconCache[symbolId]) {
2994
- return iconCache[symbolId];
3010
+ // Key the cache by url + symbol + mode: the same symbol id lives in different
3011
+ // sprite files, and different modes store different value shapes (object vs
3012
+ // string vs data-url), so keying by symbolId alone returns the wrong asset.
3013
+ // Namespace remote vs in-document sprites so a relative sprite file literally
3014
+ // named "local" can't collide with the in-document bucket.
3015
+ const cacheKey = url ? `url:${url}#${symbolId}#${mode}` : `doc:${symbolId}#${mode}`;
3016
+ if (iconCache[cacheKey]) {
3017
+ return iconCache[cacheKey];
2995
3018
  }
2996
3019
  // happens if svg spritemap is local, fastest case for us
2997
3020
  if (!url && symbolId) {
@@ -3010,7 +3033,7 @@ async function parseUseEl(useElement, mode, domParser) {
3010
3033
  ${symbol.innerHTML}
3011
3034
  </svg>
3012
3035
  `;
3013
- iconCache[symbolId] = inlineSvg;
3036
+ iconCache[cacheKey] = inlineSvg;
3014
3037
  return inlineSvg;
3015
3038
  }
3016
3039
  else {
@@ -3045,10 +3068,21 @@ async function parseUseEl(useElement, mode, domParser) {
3045
3068
  }
3046
3069
  else {
3047
3070
  svgUrlCache[url] = 1;
3048
- const response = await fetch(url);
3049
- const svgText = await response.text();
3050
- svgDoc = domParser.parseFromString(svgText, 'image/svg+xml');
3051
- svgUrlCache[url] = svgDoc;
3071
+ try {
3072
+ const response = await fetch(url);
3073
+ const svgText = await response.text();
3074
+ svgDoc = domParser.parseFromString(svgText, 'image/svg+xml');
3075
+ svgUrlCache[url] = svgDoc;
3076
+ }
3077
+ catch (e) {
3078
+ // Clear the in-flight sentinel so a failed fetch doesn't wedge every
3079
+ // future <use> for this url into a 10s poll-then-give-up loop.
3080
+ delete svgUrlCache[url];
3081
+ throw e;
3082
+ }
3083
+ }
3084
+ if (!svgDoc) {
3085
+ return;
3052
3086
  }
3053
3087
  // @ts-ignore
3054
3088
  const symbol = svgDoc.getElementById(symbolId);
@@ -3063,7 +3097,7 @@ async function parseUseEl(useElement, mode, domParser) {
3063
3097
  ${symbol.innerHTML}
3064
3098
  </svg>
3065
3099
  `;
3066
- iconCache[symbolId] = inlineSvg;
3100
+ iconCache[cacheKey] = inlineSvg;
3067
3101
  return inlineSvg;
3068
3102
  }
3069
3103
  if (mode === 'dataurl') ;
@@ -3249,6 +3283,7 @@ class Observer {
3249
3283
  }
3250
3284
  if (value === null) {
3251
3285
  this.app.send(RemoveNodeAttribute(id, name));
3286
+ return;
3252
3287
  }
3253
3288
  if (isUseElement(node) && name === 'href' && !this.disableSprites) {
3254
3289
  parseUseEl(node, 'svgtext', this.domParser)
@@ -4272,7 +4307,7 @@ class App {
4272
4307
  this.stopCallbacks = [];
4273
4308
  this.commitCallbacks = [];
4274
4309
  this.activityState = ActivityState.NotActive;
4275
- this.version = '18.0.17-beta.0'; // TODO: version compatability check inside each plugin.
4310
+ this.version = '18.1.0-beta.0'; // TODO: version compatability check inside each plugin.
4276
4311
  this.socketMode = false;
4277
4312
  this.compressionThreshold = 24 * 1000;
4278
4313
  this.bc = null;
@@ -4284,6 +4319,7 @@ class App {
4284
4319
  this.frameOderNumber = 0;
4285
4320
  this.frameLevel = 0;
4286
4321
  this.emptyBatchCounter = 0;
4322
+ this.lastHiddenAt = null;
4287
4323
  /** used by child iframes for crossdomain only */
4288
4324
  this.parentActive = false;
4289
4325
  this.checkStatus = () => {
@@ -4728,12 +4764,18 @@ class App {
4728
4764
  }
4729
4765
  };
4730
4766
  this.startTimeout = null;
4731
- this.restart = () => {
4767
+ this.restart = (forceNew = false) => {
4732
4768
  this.stop(false);
4733
4769
  this.waitStatus(ActivityState.NotActive).then(() => {
4734
4770
  this.allowAppStart();
4735
- this.start(this.prevOpts, true)
4771
+ // Force a brand-new session when asked (e.g. a stale bfcache restore) without
4772
+ // letting forceNew leak into later restarts through prevOpts.
4773
+ const startOpts = forceNew ? { ...this.prevOpts, forceNew: true } : this.prevOpts;
4774
+ this.start(startOpts, true)
4736
4775
  .then((r) => {
4776
+ if (forceNew) {
4777
+ this.prevOpts = { ...this.prevOpts, forceNew: false };
4778
+ }
4737
4779
  this.debug.info('Session restart', r);
4738
4780
  })
4739
4781
  .catch((e) => {
@@ -4741,6 +4783,34 @@ class App {
4741
4783
  });
4742
4784
  });
4743
4785
  };
4786
+ this.handlePageShow = (e) => {
4787
+ // Only back-forward cache restores set persisted === true; a normal navigation is false.
4788
+ if (!e || !e.persisted) {
4789
+ return;
4790
+ }
4791
+ // Don't resurrect a tracker that was intentionally stopped.
4792
+ if (this.activityState === ActivityState.NotActive) {
4793
+ return;
4794
+ }
4795
+ try {
4796
+ const frozenMs = this.lastHiddenAt != null ? Date.now() - this.lastHiddenAt : null;
4797
+ this.lastHiddenAt = null;
4798
+ const threshold = this.options.bfcacheRestartThreshold ?? 30 * 60 * 1000;
4799
+ // If we couldn't measure the freeze, or it outlasted the threshold, the session has
4800
+ // likely expired server-side - start a fresh one (forceNew). Otherwise let the
4801
+ // resumed page continue on the existing session (preserving continuity).
4802
+ if (frozenMs === null || frozenMs >= threshold) {
4803
+ this.debug.info('OpenReplay: restarting session after bfcache restore', { frozenMs });
4804
+ this.restart(true);
4805
+ }
4806
+ else {
4807
+ this.debug.info('OpenReplay: resuming session after bfcache restore', { frozenMs });
4808
+ }
4809
+ }
4810
+ catch (err) {
4811
+ this._debug('bfcache_restore', err);
4812
+ }
4813
+ };
4744
4814
  this.send = (message, urgent = false) => {
4745
4815
  if (this.activityState === ActivityState.NotActive) {
4746
4816
  return;
@@ -4868,6 +4938,7 @@ class App {
4868
4938
  localStorage: null,
4869
4939
  sessionStorage: null,
4870
4940
  forceSingleTab: false,
4941
+ bfcacheRestartThreshold: 30 * 60 * 1000,
4871
4942
  assistSocketHost: '',
4872
4943
  captureIFrames: true,
4873
4944
  obscureTextEmails: false,
@@ -5189,7 +5260,16 @@ class App {
5189
5260
  }
5190
5261
  };
5191
5262
  this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false);
5192
- this.attachEventListener(window, 'pagehide', alertWorker, false, false);
5263
+ this.attachEventListener(window, 'pagehide', () => {
5264
+ // pagehide is the entry point into the back-forward cache; remember when
5265
+ // it fired so pageshow can measure how long the page stayed frozen.
5266
+ this.lastHiddenAt = Date.now();
5267
+ alertWorker();
5268
+ }, false, false);
5269
+ // Detect restores from the back-forward cache. Registered through
5270
+ // attachEventListener so it's removed on stop (no leak) and a tracker that was
5271
+ // intentionally stopped does not resurrect itself on a back/forward restore.
5272
+ this.attachEventListener(window, 'pageshow', this.handlePageShow, false, false);
5193
5273
  // TODO: stop session after inactivity timeout (make configurable)
5194
5274
  this.attachEventListener(document, 'visibilitychange', (e) => document.visibilityState === 'hidden' && alertWorker(), false);
5195
5275
  }
@@ -5479,9 +5559,12 @@ class App {
5479
5559
  this.sessionStorage.removeItem(this.options.session_reset_key);
5480
5560
  }
5481
5561
  }
5562
+ getSessionVersionHash() {
5563
+ const PROTO_VERSION = '2';
5564
+ return `${PROTO_VERSION}x${this.version}`;
5565
+ }
5482
5566
  checkSessionToken(forceNew) {
5483
- const PROTO_VERSION = "2";
5484
- const versionHash = `${PROTO_VERSION}x${this.version}`;
5567
+ const versionHash = this.getSessionVersionHash();
5485
5568
  const needReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null;
5486
5569
  let needNewSessionID = forceNew || needReset;
5487
5570
  const sessionToken = this.session.getSessionToken(this.projectKey);
@@ -5812,6 +5895,7 @@ class App {
5812
5895
  }
5813
5896
  this.delay = delay;
5814
5897
  this.session.setSessionToken(token, this.projectKey);
5898
+ this.sessionStorage.setItem(`${this.options.session_token_key}_version`, this.getSessionVersionHash());
5815
5899
  if (sessionToken && sessionToken !== token) {
5816
5900
  this.bc?.postMessage({
5817
5901
  type: proto.reset,
@@ -6151,6 +6235,9 @@ function Console (app, opts) {
6151
6235
  };
6152
6236
  app.attachStartCallback(reset);
6153
6237
  app.ticker.attach(reset, 33, false);
6238
+ // Restorers for every method we patch, so we can put the host's console back
6239
+ // when the session stops instead of leaving it proxied for the page's lifetime.
6240
+ const restores = [];
6154
6241
  const patchConsole = (console, ctx) => {
6155
6242
  const handler = {
6156
6243
  apply: function (target, thisArg, argumentsList) {
@@ -6171,10 +6258,30 @@ function Console (app, opts) {
6171
6258
  }
6172
6259
  const fn = ctx.console[method];
6173
6260
  console[method] = new Proxy(fn, handler);
6261
+ restores.push(() => {
6262
+ console[method] = fn;
6263
+ });
6174
6264
  });
6175
6265
  };
6176
6266
  const patchContext = app.safe((context) => patchConsole(context.console, context));
6177
- patchContext(window);
6267
+ let patched = false;
6268
+ const startPatching = () => {
6269
+ if (patched) {
6270
+ return;
6271
+ }
6272
+ patched = true;
6273
+ patchContext(window);
6274
+ };
6275
+ // Patch immediately, and re-patch on any later start; restore on stop so the
6276
+ // host console isn't left wrapped for the page's lifetime and capture is
6277
+ // re-established symmetrically across a stop/restart.
6278
+ startPatching();
6279
+ app.attachStartCallback(startPatching);
6280
+ app.attachStopCallback(() => {
6281
+ patched = false;
6282
+ restores.forEach((restore) => restore());
6283
+ restores.length = 0;
6284
+ });
6178
6285
  app.observer.attachContextCallback(patchContext);
6179
6286
  }
6180
6287
 
@@ -6495,7 +6602,7 @@ function Img (app) {
6495
6602
  const target = mutation.target;
6496
6603
  const id = app.nodes.getID(target);
6497
6604
  if (id === undefined) {
6498
- return;
6605
+ continue;
6499
6606
  }
6500
6607
  if (mutation.attributeName === 'src') {
6501
6608
  sendSrc(id, target);
@@ -7435,15 +7542,12 @@ function CSSRules (app, opts) {
7435
7542
  if (!sheetID)
7436
7543
  continue;
7437
7544
  if (options.checkLimit) {
7438
- if (!checkIterations[sheetID]) {
7439
- checkIterations[sheetID] = 0;
7440
- }
7441
- else {
7442
- checkIterations[sheetID]++;
7443
- }
7545
+ checkIterations[sheetID] = (checkIterations[sheetID] ?? 0) + 1;
7444
7546
  if (checkIterations[sheetID] > options.checkLimit) {
7445
7547
  trackedSheets.delete(sheet);
7446
- return;
7548
+ // continue, not return: skipping one exhausted sheet must not
7549
+ // abandon the remaining tracked sheets in this scan.
7550
+ continue;
7447
7551
  }
7448
7552
  }
7449
7553
  for (let j = 0; j < sheet.cssRules.length; j++) {
@@ -9239,7 +9343,7 @@ function webAnimations(app, options = {}) {
9239
9343
  /**
9240
9344
  * Detects client browser, OS, and device information
9241
9345
  */
9242
- function uaParse(sWindow) {
9346
+ function uaParse(sWindow, onOsVersionUpdate) {
9243
9347
  const unknown = '-';
9244
9348
  // Screen detection
9245
9349
  let width = 0;
@@ -9398,7 +9502,11 @@ function uaParse(sWindow) {
9398
9502
  .getHighEntropyValues(['platformVersion'])
9399
9503
  .then((ua) => {
9400
9504
  const version = parseInt(ua.platformVersion.split('.')[0], 10);
9401
- osVersion = version < 13 ? '10' : '11';
9505
+ // uaParse has already returned by the time this resolves, so a local
9506
+ // assignment would be lost - notify the caller to update its state.
9507
+ const refined = version < 13 ? '10' : '11';
9508
+ osVersion = refined;
9509
+ onOsVersionUpdate?.(refined);
9402
9510
  })
9403
9511
  .catch(() => {
9404
9512
  // ignore errors and keep osVersion as is
@@ -9574,7 +9682,12 @@ class ConstantProperties {
9574
9682
  this.clearSuperProperties = () => {
9575
9683
  this.localStorage.setItem(superPropKey, JSON.stringify({}));
9576
9684
  };
9577
- const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } = uaParse(win);
9685
+ const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } = uaParse(win, (refinedOsVersion) => {
9686
+ // Windows 10/11 are indistinguishable from the UA string; the async
9687
+ // high-entropy result lands after construction, so update it live here.
9688
+ // `all` is a getter, so later events pick up the corrected value.
9689
+ this.osVersion = refinedOsVersion;
9690
+ });
9578
9691
  const storedUserId = this.sessionStorage.getItem(userIdKey);
9579
9692
  if (storedUserId) {
9580
9693
  this.user_id = storedUserId;
@@ -9607,7 +9720,7 @@ class ConstantProperties {
9607
9720
  user_id: this.user_id,
9608
9721
  distinct_id: this.deviceId,
9609
9722
  sdk_edition: 'web',
9610
- sdk_version: '18.0.17-beta.0',
9723
+ sdk_version: '18.1.0-beta.0',
9611
9724
  timezone: getUTCOffsetString(),
9612
9725
  search_engine: this.searchEngine,
9613
9726
  };
@@ -9957,7 +10070,15 @@ class Batcher {
9957
10070
  this.stopped = false;
9958
10071
  this.paused = false;
9959
10072
  this.onVisibilityChange = () => {
9960
- this.paused = document.hidden;
10073
+ if (document.hidden) {
10074
+ // The tab may be closing; flush now with keepalive so buffered events
10075
+ // survive the transition instead of waiting for the (paused) interval.
10076
+ this.flush(true);
10077
+ this.paused = true;
10078
+ }
10079
+ else {
10080
+ this.paused = false;
10081
+ }
9961
10082
  };
9962
10083
  }
9963
10084
  getBatches() {
@@ -10042,8 +10163,9 @@ class Batcher {
10042
10163
  }
10043
10164
  return Array.from(uniqueEventsByType.values());
10044
10165
  }
10045
- sendBatch(batch) {
10166
+ sendBatch(batch, keepalive = false, onTerminalFailure) {
10046
10167
  if (this.stopped) {
10168
+ onTerminalFailure?.();
10047
10169
  return;
10048
10170
  }
10049
10171
  const sentBatch = batch;
@@ -10051,11 +10173,14 @@ class Batcher {
10051
10173
  const send = () => {
10052
10174
  const token = this.getToken();
10053
10175
  if (!token) {
10176
+ // No token yet: keep the events so they go out once one is available.
10177
+ onTerminalFailure?.();
10054
10178
  return;
10055
10179
  }
10056
10180
  attempts++;
10057
10181
  return fetch(`${this.backendUrl}${this.apiEdp}`, {
10058
10182
  method: 'POST',
10183
+ keepalive,
10059
10184
  headers: {
10060
10185
  'Content-Type': 'application/json',
10061
10186
  Authorization: `Bearer ${token}`,
@@ -10074,6 +10199,7 @@ class Batcher {
10074
10199
  send();
10075
10200
  });
10076
10201
  }
10202
+ onTerminalFailure?.();
10077
10203
  return;
10078
10204
  }
10079
10205
  if (response.status === 403) {
@@ -10082,6 +10208,7 @@ class Batcher {
10082
10208
  send();
10083
10209
  });
10084
10210
  }
10211
+ onTerminalFailure?.();
10085
10212
  return;
10086
10213
  }
10087
10214
  if (!response.ok) {
@@ -10092,6 +10219,9 @@ class Batcher {
10092
10219
  if (attempts < this.retryLimit) {
10093
10220
  setTimeout(() => void send(), this.retryTimeout);
10094
10221
  }
10222
+ else {
10223
+ onTerminalFailure?.();
10224
+ }
10095
10225
  });
10096
10226
  };
10097
10227
  void send();
@@ -10108,16 +10238,38 @@ class Batcher {
10108
10238
  }
10109
10239
  }, this.autosendInterval);
10110
10240
  }
10111
- flush() {
10112
- const categories = Object.keys(this.batch);
10113
- const isEmpty = categories.every((category) => this.batch[category].length === 0);
10241
+ flush(keepalive = false) {
10242
+ const cats = Object.keys(this.batch);
10243
+ const isEmpty = cats.every((category) => this.batch[category].length === 0);
10114
10244
  if (isEmpty) {
10115
10245
  return;
10116
10246
  }
10117
- this.sendBatch(this.getBatches());
10118
- categories.forEach((key) => {
10247
+ const batches = this.getBatches();
10248
+ // Snapshot what we're about to send so we can restore it if the request
10249
+ // ultimately fails, instead of dropping events the moment we clear the buffer.
10250
+ const snapshot = {
10251
+ [categories.people]: [...batches.data[categories.people]],
10252
+ [categories.events]: [...batches.data[categories.events]],
10253
+ };
10254
+ cats.forEach((key) => {
10119
10255
  this.batch[key] = [];
10120
10256
  });
10257
+ this.sendBatch(batches, keepalive, () => this.requeue(snapshot));
10258
+ }
10259
+ requeue(snapshot) {
10260
+ if (this.stopped) {
10261
+ return;
10262
+ }
10263
+ // Put the failed events back at the front, ahead of anything queued since,
10264
+ // so they're retried on the next flush rather than lost.
10265
+ this.batch[categories.people] = [
10266
+ ...snapshot[categories.people],
10267
+ ...this.batch[categories.people],
10268
+ ];
10269
+ this.batch[categories.events] = [
10270
+ ...snapshot[categories.events],
10271
+ ...this.batch[categories.events],
10272
+ ];
10121
10273
  }
10122
10274
  stop() {
10123
10275
  this.flush();
@@ -10309,7 +10461,7 @@ class API {
10309
10461
  this.signalStartIssue = (reason, missingApi) => {
10310
10462
  const doNotTrack = this.checkDoNotTrack();
10311
10463
  console.log("Tracker couldn't start due to:", JSON.stringify({
10312
- trackerVersion: '18.0.17-beta.0',
10464
+ trackerVersion: '18.1.0-beta.0',
10313
10465
  projectKey: this.options.projectKey,
10314
10466
  doNotTrack,
10315
10467
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,