@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/lib/index.js CHANGED
@@ -1823,6 +1823,30 @@ class CanvasRecorder {
1823
1823
  this.MAX_QUEUE_SIZE = 50; // ~500 images max (50 batches × 10 images)
1824
1824
  this.pendingBatches = [];
1825
1825
  this.isProcessingQueue = false;
1826
+ /**
1827
+ * Reacts to a runtime sanitization change on a canvas: stop capturing if it
1828
+ * just became masked, start if it just became visible. (Already-sent frames
1829
+ * can't be retracted — escalation only stops future capture.)
1830
+ */
1831
+ this.resanitizeCanvas = (node, id) => {
1832
+ if (!hasTag(node, 'canvas')) {
1833
+ return;
1834
+ }
1835
+ const isIgnored = this.app.sanitizer.isObscured(id) || this.app.sanitizer.isHidden(id);
1836
+ if (isIgnored) {
1837
+ if (this.snapshots[id] || this.observers.has(id)) {
1838
+ const observer = this.observers.get(id);
1839
+ if (observer) {
1840
+ observer.disconnect();
1841
+ this.observers.delete(id);
1842
+ }
1843
+ this.cleanupCanvas(id);
1844
+ }
1845
+ }
1846
+ else if (!this.snapshots[id] && !this.observers.has(id)) {
1847
+ this.captureCanvas(node);
1848
+ }
1849
+ };
1826
1850
  this.restartTracking = () => {
1827
1851
  this.clear();
1828
1852
  this.app.nodes.scanTree(this.captureCanvas);
@@ -1926,6 +1950,7 @@ class CanvasRecorder {
1926
1950
  setTimeout(() => {
1927
1951
  this.app.nodes.scanTree(this.captureCanvas);
1928
1952
  this.app.nodes.attachNodeCallback(this.captureCanvas);
1953
+ this.app.attachResanitizeCallback(this.resanitizeCanvas);
1929
1954
  }, 125);
1930
1955
  }
1931
1956
  sendSnaps(images, canvasId, createdAt) {
@@ -2810,6 +2835,120 @@ function ConstructedStyleSheets (app) {
2810
2835
  });
2811
2836
  }
2812
2837
 
2838
+ var SanitizeLevel;
2839
+ (function (SanitizeLevel) {
2840
+ SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
2841
+ SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
2842
+ SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
2843
+ })(SanitizeLevel || (SanitizeLevel = {}));
2844
+ const stringWiper = (input) => input
2845
+ .trim()
2846
+ .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
2847
+ class Sanitizer {
2848
+ constructor(params) {
2849
+ // Node id -> level. Plain (0) is never stored; a missing entry means Plain.
2850
+ // A map (not the old grow-only Sets) so levels can be raised and lowered.
2851
+ this.levels = new Map();
2852
+ this.app = params.app;
2853
+ const defaultOptions = {
2854
+ obscureTextEmails: true,
2855
+ obscureTextNumbers: false,
2856
+ privateMode: false,
2857
+ domSanitizer: undefined,
2858
+ };
2859
+ this.privateMode = params.options?.privateMode ?? false;
2860
+ this.options = Object.assign(defaultOptions, params.options);
2861
+ }
2862
+ // Pure recomputation of a node's level from the live DOM + parent level.
2863
+ // Reading current state on every call is what lets resanitize() pick up
2864
+ // runtime attribute/domSanitizer changes.
2865
+ computeLevel(node, parentLevel) {
2866
+ if (this.options.privateMode) {
2867
+ if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
2868
+ return SanitizeLevel.Obscured;
2869
+ }
2870
+ if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
2871
+ return SanitizeLevel.Obscured;
2872
+ }
2873
+ }
2874
+ let level = SanitizeLevel.Plain;
2875
+ if (parentLevel >= SanitizeLevel.Obscured ||
2876
+ (isElementNode(node) &&
2877
+ (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
2878
+ level = SanitizeLevel.Obscured;
2879
+ }
2880
+ if (parentLevel === SanitizeLevel.Hidden ||
2881
+ (isElementNode(node) &&
2882
+ (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
2883
+ level = SanitizeLevel.Hidden;
2884
+ }
2885
+ if (this.options.domSanitizer !== undefined && isElementNode(node)) {
2886
+ const sanitizeLevel = this.options.domSanitizer(node);
2887
+ if (sanitizeLevel === SanitizeLevel.Obscured && level < SanitizeLevel.Obscured) {
2888
+ level = SanitizeLevel.Obscured;
2889
+ }
2890
+ if (sanitizeLevel === SanitizeLevel.Hidden) {
2891
+ level = SanitizeLevel.Hidden;
2892
+ }
2893
+ }
2894
+ return level;
2895
+ }
2896
+ getLevel(id) {
2897
+ return this.levels.get(id) ?? SanitizeLevel.Plain;
2898
+ }
2899
+ // Sets a node's level (either direction) and returns the previous one.
2900
+ setLevel(id, level) {
2901
+ const prev = this.getLevel(id);
2902
+ if (level === SanitizeLevel.Plain) {
2903
+ this.levels.delete(id);
2904
+ }
2905
+ else {
2906
+ this.levels.set(id, level);
2907
+ }
2908
+ return prev;
2909
+ }
2910
+ handleNode(id, parentID, node) {
2911
+ const level = this.computeLevel(node, this.getLevel(parentID));
2912
+ // Escalate-only: commits never lower a level, only resanitize/setLevel do.
2913
+ if (level > this.getLevel(id)) {
2914
+ this.setLevel(id, level);
2915
+ }
2916
+ }
2917
+ sanitize(id, data) {
2918
+ if (this.getLevel(id) >= SanitizeLevel.Obscured) {
2919
+ // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
2920
+ return stringWiper(data);
2921
+ }
2922
+ if (this.options.obscureTextNumbers) {
2923
+ data = data.replace(/\d/g, '0');
2924
+ }
2925
+ if (this.options.obscureTextEmails) {
2926
+ data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
2927
+ const [name, domain] = email.split('@');
2928
+ const [domainName, host] = domain.split('.');
2929
+ return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
2930
+ });
2931
+ }
2932
+ return data;
2933
+ }
2934
+ isObscured(id) {
2935
+ return this.getLevel(id) >= SanitizeLevel.Obscured;
2936
+ }
2937
+ isHidden(id) {
2938
+ return this.getLevel(id) === SanitizeLevel.Hidden;
2939
+ }
2940
+ getInnerTextSecure(el) {
2941
+ const id = this.app.nodes.getID(el);
2942
+ if (!id) {
2943
+ return '';
2944
+ }
2945
+ return this.sanitize(id, el.innerText);
2946
+ }
2947
+ clear() {
2948
+ this.levels.clear();
2949
+ }
2950
+ }
2951
+
2813
2952
  const iconCache = {};
2814
2953
  const svgUrlCache = {};
2815
2954
  async function parseUseEl(useElement, mode, domParser) {
@@ -3399,6 +3538,100 @@ class Observer {
3399
3538
  beforeCommit(this.app.nodes.getID(node));
3400
3539
  this.commitNodes(true);
3401
3540
  }
3541
+ /**
3542
+ * Re-evaluates sanitization for every tracked node in `root`'s subtree against
3543
+ * the current DOM and re-emits whatever changed. Pass the highest node you
3544
+ * changed (or the document root) so inherited levels propagate correctly.
3545
+ */
3546
+ resanitizeSubtree(root) {
3547
+ if (!isObservable(root)) {
3548
+ return;
3549
+ }
3550
+ const parent = root.parentNode;
3551
+ const parentId = parent !== null ? this.app.nodes.getID(parent) : undefined;
3552
+ const parentLevel = parentId !== undefined ? this.app.sanitizer.getLevel(parentId) : SanitizeLevel.Plain;
3553
+ this.resanitizeNode(root, parentLevel);
3554
+ }
3555
+ resanitizeNode(node, parentLevel) {
3556
+ if (isIgnored(node)) {
3557
+ return;
3558
+ }
3559
+ const id = this.app.nodes.getID(node);
3560
+ if (id === undefined) {
3561
+ // Untracked (new, or under a hidden ancestor): the live observer handles it.
3562
+ return;
3563
+ }
3564
+ const newLevel = this.app.sanitizer.computeLevel(node, parentLevel);
3565
+ const prevLevel = this.app.sanitizer.getLevel(id);
3566
+ const wasHidden = prevLevel === SanitizeLevel.Hidden;
3567
+ const willHidden = newLevel === SanitizeLevel.Hidden;
3568
+ // Crossing the hidden boundary changes the rendered structure (placeholder vs
3569
+ // real subtree), so rebuild rather than re-emit.
3570
+ if (wasHidden !== willHidden) {
3571
+ this.recreateSubtree(node);
3572
+ return;
3573
+ }
3574
+ if (willHidden) {
3575
+ return;
3576
+ }
3577
+ // Plain <-> Obscured: same structure, only leaf content changes.
3578
+ if (prevLevel !== newLevel) {
3579
+ this.app.sanitizer.setLevel(id, newLevel);
3580
+ this.reemitNode(id, node);
3581
+ }
3582
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) {
3583
+ this.resanitizeNode(child, newLevel);
3584
+ }
3585
+ }
3586
+ // Destroys the node player-side and re-emits its subtree from scratch (new ids)
3587
+ // so it materializes at the freshly-computed level.
3588
+ recreateSubtree(node) {
3589
+ const id = this.app.nodes.getID(node);
3590
+ if (id === undefined) {
3591
+ return;
3592
+ }
3593
+ this.app.send(RemoveNode(id));
3594
+ this.clearSubtreeRegistration(node);
3595
+ this.bindTree(node);
3596
+ this.commitNodes();
3597
+ }
3598
+ clearSubtreeRegistration(node) {
3599
+ const clearOne = (n) => {
3600
+ const oldId = this.app.nodes.getID(n);
3601
+ if (oldId !== undefined) {
3602
+ this.app.sanitizer.setLevel(oldId, SanitizeLevel.Plain);
3603
+ }
3604
+ this.app.nodes.unregisterNode(n);
3605
+ };
3606
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
3607
+ acceptNode: (n) => isIgnored(n) || this.app.nodes.getID(n) === undefined
3608
+ ? NodeFilter.FILTER_REJECT
3609
+ : NodeFilter.FILTER_ACCEPT,
3610
+ },
3611
+ // @ts-ignore
3612
+ false);
3613
+ // Collect first, then clear: unregistering mutates the ids the walker reads.
3614
+ const subtree = [];
3615
+ while (walker.nextNode()) {
3616
+ subtree.push(walker.currentNode);
3617
+ }
3618
+ clearOne(node);
3619
+ subtree.forEach(clearOne);
3620
+ }
3621
+ reemitNode(id, node) {
3622
+ if (isTextNode(node)) {
3623
+ const parent = node.parentNode;
3624
+ if (parent !== null && isElementNode(parent)) {
3625
+ // re-runs sanitize() at the level we just set
3626
+ this.sendNodeData(id, parent, node.data);
3627
+ }
3628
+ return;
3629
+ }
3630
+ if (isElementNode(node)) {
3631
+ // inputs/images/canvas re-emit their own payload via registered callbacks
3632
+ this.app.callResanitizeCallbacks(node, id);
3633
+ }
3634
+ }
3402
3635
  disconnect() {
3403
3636
  // THEORY S3: a disconnect may discard MutationRecords still queued by the
3404
3637
  // browser. takeRecords() drains them — they would be discarded by
@@ -3748,94 +3981,6 @@ class TopObserver extends Observer {
3748
3981
  }
3749
3982
  }
3750
3983
 
3751
- var SanitizeLevel;
3752
- (function (SanitizeLevel) {
3753
- SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
3754
- SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
3755
- SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
3756
- })(SanitizeLevel || (SanitizeLevel = {}));
3757
- const stringWiper = (input) => input
3758
- .trim()
3759
- .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
3760
- class Sanitizer {
3761
- constructor(params) {
3762
- this.obscured = new Set();
3763
- this.hidden = new Set();
3764
- this.app = params.app;
3765
- const defaultOptions = {
3766
- obscureTextEmails: true,
3767
- obscureTextNumbers: false,
3768
- privateMode: false,
3769
- domSanitizer: undefined,
3770
- };
3771
- this.privateMode = params.options?.privateMode ?? false;
3772
- this.options = Object.assign(defaultOptions, params.options);
3773
- }
3774
- handleNode(id, parentID, node) {
3775
- if (this.options.privateMode) {
3776
- if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
3777
- return this.obscured.add(id);
3778
- }
3779
- if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
3780
- return this.obscured.add(id);
3781
- }
3782
- }
3783
- if (this.obscured.has(parentID) ||
3784
- (isElementNode(node) &&
3785
- (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
3786
- this.obscured.add(id);
3787
- }
3788
- if (this.hidden.has(parentID) ||
3789
- (isElementNode(node) &&
3790
- (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
3791
- this.hidden.add(id);
3792
- }
3793
- if (this.options.domSanitizer !== undefined && isElementNode(node)) {
3794
- const sanitizeLevel = this.options.domSanitizer(node);
3795
- if (sanitizeLevel === SanitizeLevel.Obscured) {
3796
- this.obscured.add(id);
3797
- }
3798
- if (sanitizeLevel === SanitizeLevel.Hidden) {
3799
- this.hidden.add(id);
3800
- }
3801
- }
3802
- }
3803
- sanitize(id, data) {
3804
- if (this.obscured.has(id)) {
3805
- // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
3806
- return stringWiper(data);
3807
- }
3808
- if (this.options.obscureTextNumbers) {
3809
- data = data.replace(/\d/g, '0');
3810
- }
3811
- if (this.options.obscureTextEmails) {
3812
- data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
3813
- const [name, domain] = email.split('@');
3814
- const [domainName, host] = domain.split('.');
3815
- return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
3816
- });
3817
- }
3818
- return data;
3819
- }
3820
- isObscured(id) {
3821
- return this.obscured.has(id);
3822
- }
3823
- isHidden(id) {
3824
- return this.hidden.has(id);
3825
- }
3826
- getInnerTextSecure(el) {
3827
- const id = this.app.nodes.getID(el);
3828
- if (!id) {
3829
- return '';
3830
- }
3831
- return this.sanitize(id, el.innerText);
3832
- }
3833
- clear() {
3834
- this.obscured.clear();
3835
- this.hidden.clear();
3836
- }
3837
- }
3838
-
3839
3984
  const tokenSeparator = '_$_';
3840
3985
  class Session {
3841
3986
  constructor(params) {
@@ -4087,6 +4232,8 @@ class App {
4087
4232
  constructor(projectKey, sessionToken, options, signalError, insideIframe) {
4088
4233
  this.signalError = signalError;
4089
4234
  this.insideIframe = insideIframe;
4235
+ // Registered by input/img/canvas to re-emit a node when its level changes.
4236
+ this.resanitizeCallbacks = [];
4090
4237
  this.messages = [];
4091
4238
  /**
4092
4239
  * we need 2 buffers, so we don't lose anything
@@ -4098,7 +4245,7 @@ class App {
4098
4245
  this.stopCallbacks = [];
4099
4246
  this.commitCallbacks = [];
4100
4247
  this.activityState = ActivityState.NotActive;
4101
- this.version = '18.0.14-beta.2'; // TODO: version compatability check inside each plugin.
4248
+ this.version = '18.0.15'; // TODO: version compatability check inside each plugin.
4102
4249
  this.socketMode = false;
4103
4250
  this.compressionThreshold = 24 * 1000;
4104
4251
  this.bc = null;
@@ -4629,6 +4776,26 @@ class App {
4629
4776
  this.restartCanvasTracking = () => {
4630
4777
  this.canvasRecorder?.restartTracking();
4631
4778
  };
4779
+ this.attachResanitizeCallback = (cb) => {
4780
+ this.resanitizeCallbacks.push(cb);
4781
+ };
4782
+ this.callResanitizeCallbacks = (node, id) => {
4783
+ this.resanitizeCallbacks.forEach((cb) => cb(node, id));
4784
+ };
4785
+ this.resanitize = (el) => {
4786
+ const root = el ?? (IN_BROWSER ? document.documentElement : undefined);
4787
+ if (!root) {
4788
+ return;
4789
+ }
4790
+ this.observer.resanitizeSubtree(root);
4791
+ };
4792
+ this.checkSanitization = (el) => {
4793
+ const id = this.nodes.getID(el);
4794
+ if (id === undefined) {
4795
+ return undefined;
4796
+ }
4797
+ return this.sanitizer.getLevel(id);
4798
+ };
4632
4799
  this.flushBuffer = async (buffer) => {
4633
4800
  return new Promise((res, reject) => {
4634
4801
  if (buffer.length === 0) {
@@ -6324,6 +6491,12 @@ function Img (app) {
6324
6491
  sendImgAttrs(node);
6325
6492
  observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
6326
6493
  });
6494
+ // On a runtime level change, re-evaluate placeholder vs real src for this image.
6495
+ app.attachResanitizeCallback((node) => {
6496
+ if (hasTag(node, 'img')) {
6497
+ sendImgAttrs(node);
6498
+ }
6499
+ });
6327
6500
  }
6328
6501
 
6329
6502
  const INPUT_TYPES = [
@@ -6510,6 +6683,13 @@ function Input (app, opts) {
6510
6683
  }
6511
6684
  app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime));
6512
6685
  }
6686
+ // Re-emit a field's value when its sanitization level changes at runtime.
6687
+ // getInputValue() reads the current level, so re-sending applies the new mask.
6688
+ app.attachResanitizeCallback((node, id) => {
6689
+ if (isTextFieldElement(node) || hasTag(node, 'select')) {
6690
+ sendInputValue(id, node);
6691
+ }
6692
+ });
6513
6693
  app.nodes.attachNodeCallback(app.safe((node) => {
6514
6694
  const id = app.nodes.getID(node);
6515
6695
  if (id === undefined) {
@@ -9400,7 +9580,7 @@ class ConstantProperties {
9400
9580
  user_id: this.user_id,
9401
9581
  distinct_id: this.deviceId,
9402
9582
  sdk_edition: 'web',
9403
- sdk_version: '18.0.14-beta.2',
9583
+ sdk_version: '18.0.15',
9404
9584
  timezone: getUTCOffsetString(),
9405
9585
  search_engine: this.searchEngine,
9406
9586
  };
@@ -10102,7 +10282,7 @@ class API {
10102
10282
  this.signalStartIssue = (reason, missingApi) => {
10103
10283
  const doNotTrack = this.checkDoNotTrack();
10104
10284
  console.log("Tracker couldn't start due to:", JSON.stringify({
10105
- trackerVersion: '18.0.14-beta.2',
10285
+ trackerVersion: '18.0.15',
10106
10286
  projectKey: this.options.projectKey,
10107
10287
  doNotTrack,
10108
10288
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
@@ -10114,6 +10294,31 @@ class API {
10114
10294
  }
10115
10295
  this.app.restartCanvasTracking();
10116
10296
  };
10297
+ /**
10298
+ * Re-evaluates sanitization against the current DOM and re-emits whatever
10299
+ * changed, updating already-recorded nodes mid-session. Call after toggling
10300
+ * `data-openreplay-*` attributes or after changing whatever your `domSanitizer`
10301
+ * keys on (class/id/etc).
10302
+ *
10303
+ * @param el - the highest node you changed; omit to re-scan the whole document;
10304
+ * scanning the entire doc is O(dom size)
10305
+ * */
10306
+ this.resanitize = (el) => {
10307
+ if (this.app === null) {
10308
+ return;
10309
+ }
10310
+ this.app.resanitize(el);
10311
+ };
10312
+ /**
10313
+ * Returns the sanitization level the tracker currently has for a node
10314
+ * (0 = Plain, 1 = Obscured, 2 = Hidden), or undefined if it isn't tracked.
10315
+ * */
10316
+ this.checkSanitization = (el) => {
10317
+ if (this.app === null) {
10318
+ return undefined;
10319
+ }
10320
+ return this.app.checkSanitization(el);
10321
+ };
10117
10322
  this.getSessionURL = (options) => {
10118
10323
  if (this.app === null) {
10119
10324
  return undefined;
@@ -10127,7 +10332,10 @@ class API {
10127
10332
  }
10128
10333
  };
10129
10334
  this.identify = this.setUserID;
10130
- this.track = this.analytics?.track;
10335
+ // Delegates at call time: `this.analytics` is assigned in the constructor body,
10336
+ // which runs AFTER field initializers, so binding it here directly would always
10337
+ // capture `undefined`.
10338
+ this.track = (eventName, properties, options) => this.analytics?.track(eventName, properties, options);
10131
10339
  this.userID = (id) => {
10132
10340
  deprecationWarn("'userID' method", "'setUserID' method", '/');
10133
10341
  this.setUserID(id);