@openreplay/tracker 18.0.14-beta.0 → 18.0.14

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
@@ -1827,6 +1827,30 @@ class CanvasRecorder {
1827
1827
  this.MAX_QUEUE_SIZE = 50; // ~500 images max (50 batches × 10 images)
1828
1828
  this.pendingBatches = [];
1829
1829
  this.isProcessingQueue = false;
1830
+ /**
1831
+ * Reacts to a runtime sanitization change on a canvas: stop capturing if it
1832
+ * just became masked, start if it just became visible. (Already-sent frames
1833
+ * can't be retracted — escalation only stops future capture.)
1834
+ */
1835
+ this.resanitizeCanvas = (node, id) => {
1836
+ if (!hasTag(node, 'canvas')) {
1837
+ return;
1838
+ }
1839
+ const isIgnored = this.app.sanitizer.isObscured(id) || this.app.sanitizer.isHidden(id);
1840
+ if (isIgnored) {
1841
+ if (this.snapshots[id] || this.observers.has(id)) {
1842
+ const observer = this.observers.get(id);
1843
+ if (observer) {
1844
+ observer.disconnect();
1845
+ this.observers.delete(id);
1846
+ }
1847
+ this.cleanupCanvas(id);
1848
+ }
1849
+ }
1850
+ else if (!this.snapshots[id] && !this.observers.has(id)) {
1851
+ this.captureCanvas(node);
1852
+ }
1853
+ };
1830
1854
  this.restartTracking = () => {
1831
1855
  this.clear();
1832
1856
  this.app.nodes.scanTree(this.captureCanvas);
@@ -1930,6 +1954,7 @@ class CanvasRecorder {
1930
1954
  setTimeout(() => {
1931
1955
  this.app.nodes.scanTree(this.captureCanvas);
1932
1956
  this.app.nodes.attachNodeCallback(this.captureCanvas);
1957
+ this.app.attachResanitizeCallback(this.resanitizeCanvas);
1933
1958
  }, 125);
1934
1959
  }
1935
1960
  sendSnaps(images, canvasId, createdAt) {
@@ -2814,6 +2839,120 @@ function ConstructedStyleSheets (app) {
2814
2839
  });
2815
2840
  }
2816
2841
 
2842
+ exports.SanitizeLevel = void 0;
2843
+ (function (SanitizeLevel) {
2844
+ SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
2845
+ SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
2846
+ SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
2847
+ })(exports.SanitizeLevel || (exports.SanitizeLevel = {}));
2848
+ const stringWiper = (input) => input
2849
+ .trim()
2850
+ .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
2851
+ class Sanitizer {
2852
+ constructor(params) {
2853
+ // Node id -> level. Plain (0) is never stored; a missing entry means Plain.
2854
+ // A map (not the old grow-only Sets) so levels can be raised and lowered.
2855
+ this.levels = new Map();
2856
+ this.app = params.app;
2857
+ const defaultOptions = {
2858
+ obscureTextEmails: true,
2859
+ obscureTextNumbers: false,
2860
+ privateMode: false,
2861
+ domSanitizer: undefined,
2862
+ };
2863
+ this.privateMode = params.options?.privateMode ?? false;
2864
+ this.options = Object.assign(defaultOptions, params.options);
2865
+ }
2866
+ // Pure recomputation of a node's level from the live DOM + parent level.
2867
+ // Reading current state on every call is what lets resanitize() pick up
2868
+ // runtime attribute/domSanitizer changes.
2869
+ computeLevel(node, parentLevel) {
2870
+ if (this.options.privateMode) {
2871
+ if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
2872
+ return exports.SanitizeLevel.Obscured;
2873
+ }
2874
+ if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
2875
+ return exports.SanitizeLevel.Obscured;
2876
+ }
2877
+ }
2878
+ let level = exports.SanitizeLevel.Plain;
2879
+ if (parentLevel >= exports.SanitizeLevel.Obscured ||
2880
+ (isElementNode(node) &&
2881
+ (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
2882
+ level = exports.SanitizeLevel.Obscured;
2883
+ }
2884
+ if (parentLevel === exports.SanitizeLevel.Hidden ||
2885
+ (isElementNode(node) &&
2886
+ (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
2887
+ level = exports.SanitizeLevel.Hidden;
2888
+ }
2889
+ if (this.options.domSanitizer !== undefined && isElementNode(node)) {
2890
+ const sanitizeLevel = this.options.domSanitizer(node);
2891
+ if (sanitizeLevel === exports.SanitizeLevel.Obscured && level < exports.SanitizeLevel.Obscured) {
2892
+ level = exports.SanitizeLevel.Obscured;
2893
+ }
2894
+ if (sanitizeLevel === exports.SanitizeLevel.Hidden) {
2895
+ level = exports.SanitizeLevel.Hidden;
2896
+ }
2897
+ }
2898
+ return level;
2899
+ }
2900
+ getLevel(id) {
2901
+ return this.levels.get(id) ?? exports.SanitizeLevel.Plain;
2902
+ }
2903
+ // Sets a node's level (either direction) and returns the previous one.
2904
+ setLevel(id, level) {
2905
+ const prev = this.getLevel(id);
2906
+ if (level === exports.SanitizeLevel.Plain) {
2907
+ this.levels.delete(id);
2908
+ }
2909
+ else {
2910
+ this.levels.set(id, level);
2911
+ }
2912
+ return prev;
2913
+ }
2914
+ handleNode(id, parentID, node) {
2915
+ const level = this.computeLevel(node, this.getLevel(parentID));
2916
+ // Escalate-only: commits never lower a level, only resanitize/setLevel do.
2917
+ if (level > this.getLevel(id)) {
2918
+ this.setLevel(id, level);
2919
+ }
2920
+ }
2921
+ sanitize(id, data) {
2922
+ if (this.getLevel(id) >= exports.SanitizeLevel.Obscured) {
2923
+ // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
2924
+ return stringWiper(data);
2925
+ }
2926
+ if (this.options.obscureTextNumbers) {
2927
+ data = data.replace(/\d/g, '0');
2928
+ }
2929
+ if (this.options.obscureTextEmails) {
2930
+ data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
2931
+ const [name, domain] = email.split('@');
2932
+ const [domainName, host] = domain.split('.');
2933
+ return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
2934
+ });
2935
+ }
2936
+ return data;
2937
+ }
2938
+ isObscured(id) {
2939
+ return this.getLevel(id) >= exports.SanitizeLevel.Obscured;
2940
+ }
2941
+ isHidden(id) {
2942
+ return this.getLevel(id) === exports.SanitizeLevel.Hidden;
2943
+ }
2944
+ getInnerTextSecure(el) {
2945
+ const id = this.app.nodes.getID(el);
2946
+ if (!id) {
2947
+ return '';
2948
+ }
2949
+ return this.sanitize(id, el.innerText);
2950
+ }
2951
+ clear() {
2952
+ this.levels.clear();
2953
+ }
2954
+ }
2955
+
2817
2956
  const iconCache = {};
2818
2957
  const svgUrlCache = {};
2819
2958
  async function parseUseEl(useElement, mode, domParser) {
@@ -3403,6 +3542,100 @@ class Observer {
3403
3542
  beforeCommit(this.app.nodes.getID(node));
3404
3543
  this.commitNodes(true);
3405
3544
  }
3545
+ /**
3546
+ * Re-evaluates sanitization for every tracked node in `root`'s subtree against
3547
+ * the current DOM and re-emits whatever changed. Pass the highest node you
3548
+ * changed (or the document root) so inherited levels propagate correctly.
3549
+ */
3550
+ resanitizeSubtree(root) {
3551
+ if (!isObservable(root)) {
3552
+ return;
3553
+ }
3554
+ const parent = root.parentNode;
3555
+ const parentId = parent !== null ? this.app.nodes.getID(parent) : undefined;
3556
+ const parentLevel = parentId !== undefined ? this.app.sanitizer.getLevel(parentId) : exports.SanitizeLevel.Plain;
3557
+ this.resanitizeNode(root, parentLevel);
3558
+ }
3559
+ resanitizeNode(node, parentLevel) {
3560
+ if (isIgnored(node)) {
3561
+ return;
3562
+ }
3563
+ const id = this.app.nodes.getID(node);
3564
+ if (id === undefined) {
3565
+ // Untracked (new, or under a hidden ancestor): the live observer handles it.
3566
+ return;
3567
+ }
3568
+ const newLevel = this.app.sanitizer.computeLevel(node, parentLevel);
3569
+ const prevLevel = this.app.sanitizer.getLevel(id);
3570
+ const wasHidden = prevLevel === exports.SanitizeLevel.Hidden;
3571
+ const willHidden = newLevel === exports.SanitizeLevel.Hidden;
3572
+ // Crossing the hidden boundary changes the rendered structure (placeholder vs
3573
+ // real subtree), so rebuild rather than re-emit.
3574
+ if (wasHidden !== willHidden) {
3575
+ this.recreateSubtree(node);
3576
+ return;
3577
+ }
3578
+ if (willHidden) {
3579
+ return;
3580
+ }
3581
+ // Plain <-> Obscured: same structure, only leaf content changes.
3582
+ if (prevLevel !== newLevel) {
3583
+ this.app.sanitizer.setLevel(id, newLevel);
3584
+ this.reemitNode(id, node);
3585
+ }
3586
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) {
3587
+ this.resanitizeNode(child, newLevel);
3588
+ }
3589
+ }
3590
+ // Destroys the node player-side and re-emits its subtree from scratch (new ids)
3591
+ // so it materializes at the freshly-computed level.
3592
+ recreateSubtree(node) {
3593
+ const id = this.app.nodes.getID(node);
3594
+ if (id === undefined) {
3595
+ return;
3596
+ }
3597
+ this.app.send(RemoveNode(id));
3598
+ this.clearSubtreeRegistration(node);
3599
+ this.bindTree(node);
3600
+ this.commitNodes();
3601
+ }
3602
+ clearSubtreeRegistration(node) {
3603
+ const clearOne = (n) => {
3604
+ const oldId = this.app.nodes.getID(n);
3605
+ if (oldId !== undefined) {
3606
+ this.app.sanitizer.setLevel(oldId, exports.SanitizeLevel.Plain);
3607
+ }
3608
+ this.app.nodes.unregisterNode(n);
3609
+ };
3610
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
3611
+ acceptNode: (n) => isIgnored(n) || this.app.nodes.getID(n) === undefined
3612
+ ? NodeFilter.FILTER_REJECT
3613
+ : NodeFilter.FILTER_ACCEPT,
3614
+ },
3615
+ // @ts-ignore
3616
+ false);
3617
+ // Collect first, then clear: unregistering mutates the ids the walker reads.
3618
+ const subtree = [];
3619
+ while (walker.nextNode()) {
3620
+ subtree.push(walker.currentNode);
3621
+ }
3622
+ clearOne(node);
3623
+ subtree.forEach(clearOne);
3624
+ }
3625
+ reemitNode(id, node) {
3626
+ if (isTextNode(node)) {
3627
+ const parent = node.parentNode;
3628
+ if (parent !== null && isElementNode(parent)) {
3629
+ // re-runs sanitize() at the level we just set
3630
+ this.sendNodeData(id, parent, node.data);
3631
+ }
3632
+ return;
3633
+ }
3634
+ if (isElementNode(node)) {
3635
+ // inputs/images/canvas re-emit their own payload via registered callbacks
3636
+ this.app.callResanitizeCallbacks(node, id);
3637
+ }
3638
+ }
3406
3639
  disconnect() {
3407
3640
  // THEORY S3: a disconnect may discard MutationRecords still queued by the
3408
3641
  // browser. takeRecords() drains them — they would be discarded by
@@ -3762,94 +3995,6 @@ class TopObserver extends Observer {
3762
3995
  }
3763
3996
  }
3764
3997
 
3765
- exports.SanitizeLevel = void 0;
3766
- (function (SanitizeLevel) {
3767
- SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
3768
- SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
3769
- SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
3770
- })(exports.SanitizeLevel || (exports.SanitizeLevel = {}));
3771
- const stringWiper = (input) => input
3772
- .trim()
3773
- .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
3774
- class Sanitizer {
3775
- constructor(params) {
3776
- this.obscured = new Set();
3777
- this.hidden = new Set();
3778
- this.app = params.app;
3779
- const defaultOptions = {
3780
- obscureTextEmails: true,
3781
- obscureTextNumbers: false,
3782
- privateMode: false,
3783
- domSanitizer: undefined,
3784
- };
3785
- this.privateMode = params.options?.privateMode ?? false;
3786
- this.options = Object.assign(defaultOptions, params.options);
3787
- }
3788
- handleNode(id, parentID, node) {
3789
- if (this.options.privateMode) {
3790
- if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
3791
- return this.obscured.add(id);
3792
- }
3793
- if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
3794
- return this.obscured.add(id);
3795
- }
3796
- }
3797
- if (this.obscured.has(parentID) ||
3798
- (isElementNode(node) &&
3799
- (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
3800
- this.obscured.add(id);
3801
- }
3802
- if (this.hidden.has(parentID) ||
3803
- (isElementNode(node) &&
3804
- (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
3805
- this.hidden.add(id);
3806
- }
3807
- if (this.options.domSanitizer !== undefined && isElementNode(node)) {
3808
- const sanitizeLevel = this.options.domSanitizer(node);
3809
- if (sanitizeLevel === exports.SanitizeLevel.Obscured) {
3810
- this.obscured.add(id);
3811
- }
3812
- if (sanitizeLevel === exports.SanitizeLevel.Hidden) {
3813
- this.hidden.add(id);
3814
- }
3815
- }
3816
- }
3817
- sanitize(id, data) {
3818
- if (this.obscured.has(id)) {
3819
- // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
3820
- return stringWiper(data);
3821
- }
3822
- if (this.options.obscureTextNumbers) {
3823
- data = data.replace(/\d/g, '0');
3824
- }
3825
- if (this.options.obscureTextEmails) {
3826
- data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
3827
- const [name, domain] = email.split('@');
3828
- const [domainName, host] = domain.split('.');
3829
- return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
3830
- });
3831
- }
3832
- return data;
3833
- }
3834
- isObscured(id) {
3835
- return this.obscured.has(id);
3836
- }
3837
- isHidden(id) {
3838
- return this.hidden.has(id);
3839
- }
3840
- getInnerTextSecure(el) {
3841
- const id = this.app.nodes.getID(el);
3842
- if (!id) {
3843
- return '';
3844
- }
3845
- return this.sanitize(id, el.innerText);
3846
- }
3847
- clear() {
3848
- this.obscured.clear();
3849
- this.hidden.clear();
3850
- }
3851
- }
3852
-
3853
3998
  const tokenSeparator = '_$_';
3854
3999
  class Session {
3855
4000
  constructor(params) {
@@ -4101,6 +4246,8 @@ class App {
4101
4246
  constructor(projectKey, sessionToken, options, signalError, insideIframe) {
4102
4247
  this.signalError = signalError;
4103
4248
  this.insideIframe = insideIframe;
4249
+ // Registered by input/img/canvas to re-emit a node when its level changes.
4250
+ this.resanitizeCallbacks = [];
4104
4251
  this.messages = [];
4105
4252
  /**
4106
4253
  * we need 2 buffers, so we don't lose anything
@@ -4112,7 +4259,7 @@ class App {
4112
4259
  this.stopCallbacks = [];
4113
4260
  this.commitCallbacks = [];
4114
4261
  this.activityState = ActivityState.NotActive;
4115
- this.version = '18.0.14-beta.0'; // TODO: version compatability check inside each plugin.
4262
+ this.version = '18.0.14'; // TODO: version compatability check inside each plugin.
4116
4263
  this.socketMode = false;
4117
4264
  this.compressionThreshold = 24 * 1000;
4118
4265
  this.bc = null;
@@ -4643,6 +4790,26 @@ class App {
4643
4790
  this.restartCanvasTracking = () => {
4644
4791
  this.canvasRecorder?.restartTracking();
4645
4792
  };
4793
+ this.attachResanitizeCallback = (cb) => {
4794
+ this.resanitizeCallbacks.push(cb);
4795
+ };
4796
+ this.callResanitizeCallbacks = (node, id) => {
4797
+ this.resanitizeCallbacks.forEach((cb) => cb(node, id));
4798
+ };
4799
+ this.resanitize = (el) => {
4800
+ const root = el ?? (IN_BROWSER ? document.documentElement : undefined);
4801
+ if (!root) {
4802
+ return;
4803
+ }
4804
+ this.observer.resanitizeSubtree(root);
4805
+ };
4806
+ this.checkSanitization = (el) => {
4807
+ const id = this.nodes.getID(el);
4808
+ if (id === undefined) {
4809
+ return undefined;
4810
+ }
4811
+ return this.sanitizer.getLevel(id);
4812
+ };
4646
4813
  this.flushBuffer = async (buffer) => {
4647
4814
  return new Promise((res, reject) => {
4648
4815
  if (buffer.length === 0) {
@@ -6335,6 +6502,12 @@ function Img (app) {
6335
6502
  sendImgAttrs(node);
6336
6503
  observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
6337
6504
  });
6505
+ // On a runtime level change, re-evaluate placeholder vs real src for this image.
6506
+ app.attachResanitizeCallback((node) => {
6507
+ if (hasTag(node, 'img')) {
6508
+ sendImgAttrs(node);
6509
+ }
6510
+ });
6338
6511
  }
6339
6512
 
6340
6513
  const INPUT_TYPES = [
@@ -6521,6 +6694,13 @@ function Input (app, opts) {
6521
6694
  }
6522
6695
  app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime));
6523
6696
  }
6697
+ // Re-emit a field's value when its sanitization level changes at runtime.
6698
+ // getInputValue() reads the current level, so re-sending applies the new mask.
6699
+ app.attachResanitizeCallback((node, id) => {
6700
+ if (isTextFieldElement(node) || hasTag(node, 'select')) {
6701
+ sendInputValue(id, node);
6702
+ }
6703
+ });
6524
6704
  app.nodes.attachNodeCallback(app.safe((node) => {
6525
6705
  const id = app.nodes.getID(node);
6526
6706
  if (id === undefined) {
@@ -9411,7 +9591,7 @@ class ConstantProperties {
9411
9591
  user_id: this.user_id,
9412
9592
  distinct_id: this.deviceId,
9413
9593
  sdk_edition: 'web',
9414
- sdk_version: '18.0.14-beta.0',
9594
+ sdk_version: '18.0.14',
9415
9595
  timezone: getUTCOffsetString(),
9416
9596
  search_engine: this.searchEngine,
9417
9597
  };
@@ -10113,7 +10293,7 @@ class API {
10113
10293
  this.signalStartIssue = (reason, missingApi) => {
10114
10294
  const doNotTrack = this.checkDoNotTrack();
10115
10295
  console.log("Tracker couldn't start due to:", JSON.stringify({
10116
- trackerVersion: '18.0.14-beta.0',
10296
+ trackerVersion: '18.0.14',
10117
10297
  projectKey: this.options.projectKey,
10118
10298
  doNotTrack,
10119
10299
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
@@ -10125,6 +10305,31 @@ class API {
10125
10305
  }
10126
10306
  this.app.restartCanvasTracking();
10127
10307
  };
10308
+ /**
10309
+ * Re-evaluates sanitization against the current DOM and re-emits whatever
10310
+ * changed, updating already-recorded nodes mid-session. Call after toggling
10311
+ * `data-openreplay-*` attributes or after changing whatever your `domSanitizer`
10312
+ * keys on (class/id/etc).
10313
+ *
10314
+ * @param el - the highest node you changed; omit to re-scan the whole document;
10315
+ * scanning the entire doc is O(dom size)
10316
+ * */
10317
+ this.resanitize = (el) => {
10318
+ if (this.app === null) {
10319
+ return;
10320
+ }
10321
+ this.app.resanitize(el);
10322
+ };
10323
+ /**
10324
+ * Returns the sanitization level the tracker currently has for a node
10325
+ * (0 = Plain, 1 = Obscured, 2 = Hidden), or undefined if it isn't tracked.
10326
+ * */
10327
+ this.checkSanitization = (el) => {
10328
+ if (this.app === null) {
10329
+ return undefined;
10330
+ }
10331
+ return this.app.checkSanitization(el);
10332
+ };
10128
10333
  this.getSessionURL = (options) => {
10129
10334
  if (this.app === null) {
10130
10335
  return undefined;
@@ -10138,7 +10343,10 @@ class API {
10138
10343
  }
10139
10344
  };
10140
10345
  this.identify = this.setUserID;
10141
- this.track = this.analytics?.track;
10346
+ // Delegates at call time: `this.analytics` is assigned in the constructor body,
10347
+ // which runs AFTER field initializers, so binding it here directly would always
10348
+ // capture `undefined`.
10349
+ this.track = (eventName, properties, options) => this.analytics?.track(eventName, properties, options);
10142
10350
  this.userID = (id) => {
10143
10351
  deprecationWarn("'userID' method", "'setUserID' method", '/');
10144
10352
  this.setUserID(id);