@openreplay/tracker 18.0.14-beta.2 → 18.0.15

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
@@ -3752,94 +3985,6 @@ class TopObserver extends Observer {
3752
3985
  }
3753
3986
  }
3754
3987
 
3755
- exports.SanitizeLevel = void 0;
3756
- (function (SanitizeLevel) {
3757
- SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
3758
- SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
3759
- SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
3760
- })(exports.SanitizeLevel || (exports.SanitizeLevel = {}));
3761
- const stringWiper = (input) => input
3762
- .trim()
3763
- .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
3764
- class Sanitizer {
3765
- constructor(params) {
3766
- this.obscured = new Set();
3767
- this.hidden = new Set();
3768
- this.app = params.app;
3769
- const defaultOptions = {
3770
- obscureTextEmails: true,
3771
- obscureTextNumbers: false,
3772
- privateMode: false,
3773
- domSanitizer: undefined,
3774
- };
3775
- this.privateMode = params.options?.privateMode ?? false;
3776
- this.options = Object.assign(defaultOptions, params.options);
3777
- }
3778
- handleNode(id, parentID, node) {
3779
- if (this.options.privateMode) {
3780
- if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
3781
- return this.obscured.add(id);
3782
- }
3783
- if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
3784
- return this.obscured.add(id);
3785
- }
3786
- }
3787
- if (this.obscured.has(parentID) ||
3788
- (isElementNode(node) &&
3789
- (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
3790
- this.obscured.add(id);
3791
- }
3792
- if (this.hidden.has(parentID) ||
3793
- (isElementNode(node) &&
3794
- (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
3795
- this.hidden.add(id);
3796
- }
3797
- if (this.options.domSanitizer !== undefined && isElementNode(node)) {
3798
- const sanitizeLevel = this.options.domSanitizer(node);
3799
- if (sanitizeLevel === exports.SanitizeLevel.Obscured) {
3800
- this.obscured.add(id);
3801
- }
3802
- if (sanitizeLevel === exports.SanitizeLevel.Hidden) {
3803
- this.hidden.add(id);
3804
- }
3805
- }
3806
- }
3807
- sanitize(id, data) {
3808
- if (this.obscured.has(id)) {
3809
- // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
3810
- return stringWiper(data);
3811
- }
3812
- if (this.options.obscureTextNumbers) {
3813
- data = data.replace(/\d/g, '0');
3814
- }
3815
- if (this.options.obscureTextEmails) {
3816
- data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
3817
- const [name, domain] = email.split('@');
3818
- const [domainName, host] = domain.split('.');
3819
- return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
3820
- });
3821
- }
3822
- return data;
3823
- }
3824
- isObscured(id) {
3825
- return this.obscured.has(id);
3826
- }
3827
- isHidden(id) {
3828
- return this.hidden.has(id);
3829
- }
3830
- getInnerTextSecure(el) {
3831
- const id = this.app.nodes.getID(el);
3832
- if (!id) {
3833
- return '';
3834
- }
3835
- return this.sanitize(id, el.innerText);
3836
- }
3837
- clear() {
3838
- this.obscured.clear();
3839
- this.hidden.clear();
3840
- }
3841
- }
3842
-
3843
3988
  const tokenSeparator = '_$_';
3844
3989
  class Session {
3845
3990
  constructor(params) {
@@ -4091,6 +4236,8 @@ class App {
4091
4236
  constructor(projectKey, sessionToken, options, signalError, insideIframe) {
4092
4237
  this.signalError = signalError;
4093
4238
  this.insideIframe = insideIframe;
4239
+ // Registered by input/img/canvas to re-emit a node when its level changes.
4240
+ this.resanitizeCallbacks = [];
4094
4241
  this.messages = [];
4095
4242
  /**
4096
4243
  * we need 2 buffers, so we don't lose anything
@@ -4102,7 +4249,7 @@ class App {
4102
4249
  this.stopCallbacks = [];
4103
4250
  this.commitCallbacks = [];
4104
4251
  this.activityState = ActivityState.NotActive;
4105
- this.version = '18.0.14-beta.2'; // TODO: version compatability check inside each plugin.
4252
+ this.version = '18.0.15'; // TODO: version compatability check inside each plugin.
4106
4253
  this.socketMode = false;
4107
4254
  this.compressionThreshold = 24 * 1000;
4108
4255
  this.bc = null;
@@ -4633,6 +4780,26 @@ class App {
4633
4780
  this.restartCanvasTracking = () => {
4634
4781
  this.canvasRecorder?.restartTracking();
4635
4782
  };
4783
+ this.attachResanitizeCallback = (cb) => {
4784
+ this.resanitizeCallbacks.push(cb);
4785
+ };
4786
+ this.callResanitizeCallbacks = (node, id) => {
4787
+ this.resanitizeCallbacks.forEach((cb) => cb(node, id));
4788
+ };
4789
+ this.resanitize = (el) => {
4790
+ const root = el ?? (IN_BROWSER ? document.documentElement : undefined);
4791
+ if (!root) {
4792
+ return;
4793
+ }
4794
+ this.observer.resanitizeSubtree(root);
4795
+ };
4796
+ this.checkSanitization = (el) => {
4797
+ const id = this.nodes.getID(el);
4798
+ if (id === undefined) {
4799
+ return undefined;
4800
+ }
4801
+ return this.sanitizer.getLevel(id);
4802
+ };
4636
4803
  this.flushBuffer = async (buffer) => {
4637
4804
  return new Promise((res, reject) => {
4638
4805
  if (buffer.length === 0) {
@@ -6328,6 +6495,12 @@ function Img (app) {
6328
6495
  sendImgAttrs(node);
6329
6496
  observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
6330
6497
  });
6498
+ // On a runtime level change, re-evaluate placeholder vs real src for this image.
6499
+ app.attachResanitizeCallback((node) => {
6500
+ if (hasTag(node, 'img')) {
6501
+ sendImgAttrs(node);
6502
+ }
6503
+ });
6331
6504
  }
6332
6505
 
6333
6506
  const INPUT_TYPES = [
@@ -6514,6 +6687,13 @@ function Input (app, opts) {
6514
6687
  }
6515
6688
  app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime));
6516
6689
  }
6690
+ // Re-emit a field's value when its sanitization level changes at runtime.
6691
+ // getInputValue() reads the current level, so re-sending applies the new mask.
6692
+ app.attachResanitizeCallback((node, id) => {
6693
+ if (isTextFieldElement(node) || hasTag(node, 'select')) {
6694
+ sendInputValue(id, node);
6695
+ }
6696
+ });
6517
6697
  app.nodes.attachNodeCallback(app.safe((node) => {
6518
6698
  const id = app.nodes.getID(node);
6519
6699
  if (id === undefined) {
@@ -9404,7 +9584,7 @@ class ConstantProperties {
9404
9584
  user_id: this.user_id,
9405
9585
  distinct_id: this.deviceId,
9406
9586
  sdk_edition: 'web',
9407
- sdk_version: '18.0.14-beta.2',
9587
+ sdk_version: '18.0.15',
9408
9588
  timezone: getUTCOffsetString(),
9409
9589
  search_engine: this.searchEngine,
9410
9590
  };
@@ -10106,7 +10286,7 @@ class API {
10106
10286
  this.signalStartIssue = (reason, missingApi) => {
10107
10287
  const doNotTrack = this.checkDoNotTrack();
10108
10288
  console.log("Tracker couldn't start due to:", JSON.stringify({
10109
- trackerVersion: '18.0.14-beta.2',
10289
+ trackerVersion: '18.0.15',
10110
10290
  projectKey: this.options.projectKey,
10111
10291
  doNotTrack,
10112
10292
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
@@ -10118,6 +10298,31 @@ class API {
10118
10298
  }
10119
10299
  this.app.restartCanvasTracking();
10120
10300
  };
10301
+ /**
10302
+ * Re-evaluates sanitization against the current DOM and re-emits whatever
10303
+ * changed, updating already-recorded nodes mid-session. Call after toggling
10304
+ * `data-openreplay-*` attributes or after changing whatever your `domSanitizer`
10305
+ * keys on (class/id/etc).
10306
+ *
10307
+ * @param el - the highest node you changed; omit to re-scan the whole document;
10308
+ * scanning the entire doc is O(dom size)
10309
+ * */
10310
+ this.resanitize = (el) => {
10311
+ if (this.app === null) {
10312
+ return;
10313
+ }
10314
+ this.app.resanitize(el);
10315
+ };
10316
+ /**
10317
+ * Returns the sanitization level the tracker currently has for a node
10318
+ * (0 = Plain, 1 = Obscured, 2 = Hidden), or undefined if it isn't tracked.
10319
+ * */
10320
+ this.checkSanitization = (el) => {
10321
+ if (this.app === null) {
10322
+ return undefined;
10323
+ }
10324
+ return this.app.checkSanitization(el);
10325
+ };
10121
10326
  this.getSessionURL = (options) => {
10122
10327
  if (this.app === null) {
10123
10328
  return undefined;
@@ -10131,7 +10336,10 @@ class API {
10131
10336
  }
10132
10337
  };
10133
10338
  this.identify = this.setUserID;
10134
- this.track = this.analytics?.track;
10339
+ // Delegates at call time: `this.analytics` is assigned in the constructor body,
10340
+ // which runs AFTER field initializers, so binding it here directly would always
10341
+ // capture `undefined`.
10342
+ this.track = (eventName, properties, options) => this.analytics?.track(eventName, properties, options);
10135
10343
  this.userID = (id) => {
10136
10344
  deprecationWarn("'userID' method", "'setUserID' method", '/');
10137
10345
  this.setUserID(id);