@openreplay/tracker 17.2.9 → 17.2.11

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/entry.js CHANGED
@@ -1788,6 +1788,30 @@ class CanvasRecorder {
1788
1788
  this.MAX_QUEUE_SIZE = 50; // ~500 images max (50 batches × 10 images)
1789
1789
  this.pendingBatches = [];
1790
1790
  this.isProcessingQueue = false;
1791
+ /**
1792
+ * Reacts to a runtime sanitization change on a canvas: stop capturing if it
1793
+ * just became masked, start if it just became visible. (Already-sent frames
1794
+ * can't be retracted — escalation only stops future capture.)
1795
+ */
1796
+ this.resanitizeCanvas = (node, id) => {
1797
+ if (!hasTag(node, 'canvas')) {
1798
+ return;
1799
+ }
1800
+ const isIgnored = this.app.sanitizer.isObscured(id) || this.app.sanitizer.isHidden(id);
1801
+ if (isIgnored) {
1802
+ if (this.snapshots[id] || this.observers.has(id)) {
1803
+ const observer = this.observers.get(id);
1804
+ if (observer) {
1805
+ observer.disconnect();
1806
+ this.observers.delete(id);
1807
+ }
1808
+ this.cleanupCanvas(id);
1809
+ }
1810
+ }
1811
+ else if (!this.snapshots[id] && !this.observers.has(id)) {
1812
+ this.captureCanvas(node);
1813
+ }
1814
+ };
1791
1815
  this.restartTracking = () => {
1792
1816
  this.clear();
1793
1817
  this.app.nodes.scanTree(this.captureCanvas);
@@ -1891,6 +1915,7 @@ class CanvasRecorder {
1891
1915
  setTimeout(() => {
1892
1916
  this.app.nodes.scanTree(this.captureCanvas);
1893
1917
  this.app.nodes.attachNodeCallback(this.captureCanvas);
1918
+ this.app.attachResanitizeCallback(this.resanitizeCanvas);
1894
1919
  }, 125);
1895
1920
  }
1896
1921
  sendSnaps(images, canvasId, createdAt) {
@@ -2762,6 +2787,120 @@ function ConstructedStyleSheets (app) {
2762
2787
  });
2763
2788
  }
2764
2789
 
2790
+ exports.SanitizeLevel = void 0;
2791
+ (function (SanitizeLevel) {
2792
+ SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
2793
+ SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
2794
+ SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
2795
+ })(exports.SanitizeLevel || (exports.SanitizeLevel = {}));
2796
+ const stringWiper = (input) => input
2797
+ .trim()
2798
+ .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
2799
+ class Sanitizer {
2800
+ constructor(params) {
2801
+ // Node id -> level. Plain (0) is never stored; a missing entry means Plain.
2802
+ // A map (not the old grow-only Sets) so levels can be raised and lowered.
2803
+ this.levels = new Map();
2804
+ this.app = params.app;
2805
+ const defaultOptions = {
2806
+ obscureTextEmails: true,
2807
+ obscureTextNumbers: false,
2808
+ privateMode: false,
2809
+ domSanitizer: undefined,
2810
+ };
2811
+ this.privateMode = params.options?.privateMode ?? false;
2812
+ this.options = Object.assign(defaultOptions, params.options);
2813
+ }
2814
+ // Pure recomputation of a node's level from the live DOM + parent level.
2815
+ // Reading current state on every call is what lets resanitize() pick up
2816
+ // runtime attribute/domSanitizer changes.
2817
+ computeLevel(node, parentLevel) {
2818
+ if (this.options.privateMode) {
2819
+ if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
2820
+ return exports.SanitizeLevel.Obscured;
2821
+ }
2822
+ if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
2823
+ return exports.SanitizeLevel.Obscured;
2824
+ }
2825
+ }
2826
+ let level = exports.SanitizeLevel.Plain;
2827
+ if (parentLevel >= exports.SanitizeLevel.Obscured ||
2828
+ (isElementNode(node) &&
2829
+ (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
2830
+ level = exports.SanitizeLevel.Obscured;
2831
+ }
2832
+ if (parentLevel === exports.SanitizeLevel.Hidden ||
2833
+ (isElementNode(node) &&
2834
+ (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
2835
+ level = exports.SanitizeLevel.Hidden;
2836
+ }
2837
+ if (this.options.domSanitizer !== undefined && isElementNode(node)) {
2838
+ const sanitizeLevel = this.options.domSanitizer(node);
2839
+ if (sanitizeLevel === exports.SanitizeLevel.Obscured && level < exports.SanitizeLevel.Obscured) {
2840
+ level = exports.SanitizeLevel.Obscured;
2841
+ }
2842
+ if (sanitizeLevel === exports.SanitizeLevel.Hidden) {
2843
+ level = exports.SanitizeLevel.Hidden;
2844
+ }
2845
+ }
2846
+ return level;
2847
+ }
2848
+ getLevel(id) {
2849
+ return this.levels.get(id) ?? exports.SanitizeLevel.Plain;
2850
+ }
2851
+ // Sets a node's level (either direction) and returns the previous one.
2852
+ setLevel(id, level) {
2853
+ const prev = this.getLevel(id);
2854
+ if (level === exports.SanitizeLevel.Plain) {
2855
+ this.levels.delete(id);
2856
+ }
2857
+ else {
2858
+ this.levels.set(id, level);
2859
+ }
2860
+ return prev;
2861
+ }
2862
+ handleNode(id, parentID, node) {
2863
+ const level = this.computeLevel(node, this.getLevel(parentID));
2864
+ // Escalate-only: commits never lower a level, only resanitize/setLevel do.
2865
+ if (level > this.getLevel(id)) {
2866
+ this.setLevel(id, level);
2867
+ }
2868
+ }
2869
+ sanitize(id, data) {
2870
+ if (this.getLevel(id) >= exports.SanitizeLevel.Obscured) {
2871
+ // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
2872
+ return stringWiper(data);
2873
+ }
2874
+ if (this.options.obscureTextNumbers) {
2875
+ data = data.replace(/\d/g, '0');
2876
+ }
2877
+ if (this.options.obscureTextEmails) {
2878
+ data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
2879
+ const [name, domain] = email.split('@');
2880
+ const [domainName, host] = domain.split('.');
2881
+ return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
2882
+ });
2883
+ }
2884
+ return data;
2885
+ }
2886
+ isObscured(id) {
2887
+ return this.getLevel(id) >= exports.SanitizeLevel.Obscured;
2888
+ }
2889
+ isHidden(id) {
2890
+ return this.getLevel(id) === exports.SanitizeLevel.Hidden;
2891
+ }
2892
+ getInnerTextSecure(el) {
2893
+ const id = this.app.nodes.getID(el);
2894
+ if (!id) {
2895
+ return '';
2896
+ }
2897
+ return this.sanitize(id, el.innerText);
2898
+ }
2899
+ clear() {
2900
+ this.levels.clear();
2901
+ }
2902
+ }
2903
+
2765
2904
  const iconCache = {};
2766
2905
  const svgUrlCache = {};
2767
2906
  async function parseUseEl(useElement, mode, domParser) {
@@ -3341,6 +3480,100 @@ class Observer {
3341
3480
  beforeCommit(this.app.nodes.getID(node));
3342
3481
  this.commitNodes(true);
3343
3482
  }
3483
+ /**
3484
+ * Re-evaluates sanitization for every tracked node in `root`'s subtree against
3485
+ * the current DOM and re-emits whatever changed. Pass the highest node you
3486
+ * changed (or the document root) so inherited levels propagate correctly.
3487
+ */
3488
+ resanitizeSubtree(root) {
3489
+ if (!isObservable(root)) {
3490
+ return;
3491
+ }
3492
+ const parent = root.parentNode;
3493
+ const parentId = parent !== null ? this.app.nodes.getID(parent) : undefined;
3494
+ const parentLevel = parentId !== undefined ? this.app.sanitizer.getLevel(parentId) : exports.SanitizeLevel.Plain;
3495
+ this.resanitizeNode(root, parentLevel);
3496
+ }
3497
+ resanitizeNode(node, parentLevel) {
3498
+ if (isIgnored(node)) {
3499
+ return;
3500
+ }
3501
+ const id = this.app.nodes.getID(node);
3502
+ if (id === undefined) {
3503
+ // Untracked (new, or under a hidden ancestor): the live observer handles it.
3504
+ return;
3505
+ }
3506
+ const newLevel = this.app.sanitizer.computeLevel(node, parentLevel);
3507
+ const prevLevel = this.app.sanitizer.getLevel(id);
3508
+ const wasHidden = prevLevel === exports.SanitizeLevel.Hidden;
3509
+ const willHidden = newLevel === exports.SanitizeLevel.Hidden;
3510
+ // Crossing the hidden boundary changes the rendered structure (placeholder vs
3511
+ // real subtree), so rebuild rather than re-emit.
3512
+ if (wasHidden !== willHidden) {
3513
+ this.recreateSubtree(node);
3514
+ return;
3515
+ }
3516
+ if (willHidden) {
3517
+ return;
3518
+ }
3519
+ // Plain <-> Obscured: same structure, only leaf content changes.
3520
+ if (prevLevel !== newLevel) {
3521
+ this.app.sanitizer.setLevel(id, newLevel);
3522
+ this.reemitNode(id, node);
3523
+ }
3524
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) {
3525
+ this.resanitizeNode(child, newLevel);
3526
+ }
3527
+ }
3528
+ // Destroys the node player-side and re-emits its subtree from scratch (new ids)
3529
+ // so it materializes at the freshly-computed level.
3530
+ recreateSubtree(node) {
3531
+ const id = this.app.nodes.getID(node);
3532
+ if (id === undefined) {
3533
+ return;
3534
+ }
3535
+ this.app.send(RemoveNode(id));
3536
+ this.clearSubtreeRegistration(node);
3537
+ this.bindTree(node);
3538
+ this.commitNodes();
3539
+ }
3540
+ clearSubtreeRegistration(node) {
3541
+ const clearOne = (n) => {
3542
+ const oldId = this.app.nodes.getID(n);
3543
+ if (oldId !== undefined) {
3544
+ this.app.sanitizer.setLevel(oldId, exports.SanitizeLevel.Plain);
3545
+ }
3546
+ this.app.nodes.unregisterNode(n);
3547
+ };
3548
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
3549
+ acceptNode: (n) => isIgnored(n) || this.app.nodes.getID(n) === undefined
3550
+ ? NodeFilter.FILTER_REJECT
3551
+ : NodeFilter.FILTER_ACCEPT,
3552
+ },
3553
+ // @ts-ignore
3554
+ false);
3555
+ // Collect first, then clear: unregistering mutates the ids the walker reads.
3556
+ const subtree = [];
3557
+ while (walker.nextNode()) {
3558
+ subtree.push(walker.currentNode);
3559
+ }
3560
+ clearOne(node);
3561
+ subtree.forEach(clearOne);
3562
+ }
3563
+ reemitNode(id, node) {
3564
+ if (isTextNode(node)) {
3565
+ const parent = node.parentNode;
3566
+ if (parent !== null && isElementNode(parent)) {
3567
+ // re-runs sanitize() at the level we just set
3568
+ this.sendNodeData(id, parent, node.data);
3569
+ }
3570
+ return;
3571
+ }
3572
+ if (isElementNode(node)) {
3573
+ // inputs/images/canvas re-emit their own payload via registered callbacks
3574
+ this.app.callResanitizeCallbacks(node, id);
3575
+ }
3576
+ }
3344
3577
  disconnect() {
3345
3578
  this.observer.disconnect();
3346
3579
  this.clear();
@@ -3663,94 +3896,6 @@ class TopObserver extends Observer {
3663
3896
  }
3664
3897
  }
3665
3898
 
3666
- exports.SanitizeLevel = void 0;
3667
- (function (SanitizeLevel) {
3668
- SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
3669
- SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
3670
- SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
3671
- })(exports.SanitizeLevel || (exports.SanitizeLevel = {}));
3672
- const stringWiper = (input) => input
3673
- .trim()
3674
- .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
3675
- class Sanitizer {
3676
- constructor(params) {
3677
- this.obscured = new Set();
3678
- this.hidden = new Set();
3679
- this.app = params.app;
3680
- const defaultOptions = {
3681
- obscureTextEmails: true,
3682
- obscureTextNumbers: false,
3683
- privateMode: false,
3684
- domSanitizer: undefined,
3685
- };
3686
- this.privateMode = params.options?.privateMode ?? false;
3687
- this.options = Object.assign(defaultOptions, params.options);
3688
- }
3689
- handleNode(id, parentID, node) {
3690
- if (this.options.privateMode) {
3691
- if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
3692
- return this.obscured.add(id);
3693
- }
3694
- if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
3695
- return this.obscured.add(id);
3696
- }
3697
- }
3698
- if (this.obscured.has(parentID) ||
3699
- (isElementNode(node) &&
3700
- (hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
3701
- this.obscured.add(id);
3702
- }
3703
- if (this.hidden.has(parentID) ||
3704
- (isElementNode(node) &&
3705
- (hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
3706
- this.hidden.add(id);
3707
- }
3708
- if (this.options.domSanitizer !== undefined && isElementNode(node)) {
3709
- const sanitizeLevel = this.options.domSanitizer(node);
3710
- if (sanitizeLevel === exports.SanitizeLevel.Obscured) {
3711
- this.obscured.add(id);
3712
- }
3713
- if (sanitizeLevel === exports.SanitizeLevel.Hidden) {
3714
- this.hidden.add(id);
3715
- }
3716
- }
3717
- }
3718
- sanitize(id, data) {
3719
- if (this.obscured.has(id)) {
3720
- // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
3721
- return stringWiper(data);
3722
- }
3723
- if (this.options.obscureTextNumbers) {
3724
- data = data.replace(/\d/g, '0');
3725
- }
3726
- if (this.options.obscureTextEmails) {
3727
- data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
3728
- const [name, domain] = email.split('@');
3729
- const [domainName, host] = domain.split('.');
3730
- return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
3731
- });
3732
- }
3733
- return data;
3734
- }
3735
- isObscured(id) {
3736
- return this.obscured.has(id);
3737
- }
3738
- isHidden(id) {
3739
- return this.hidden.has(id);
3740
- }
3741
- getInnerTextSecure(el) {
3742
- const id = this.app.nodes.getID(el);
3743
- if (!id) {
3744
- return '';
3745
- }
3746
- return this.sanitize(id, el.innerText);
3747
- }
3748
- clear() {
3749
- this.obscured.clear();
3750
- this.hidden.clear();
3751
- }
3752
- }
3753
-
3754
3899
  const tokenSeparator = '_$_';
3755
3900
  class Session {
3756
3901
  constructor(params) {
@@ -3985,12 +4130,16 @@ const proto = {
3985
4130
  parentAlive: 'signal that parent is live',
3986
4131
  killIframe: 'stop tracker inside frame',
3987
4132
  startIframe: 'start tracker inside frame',
4133
+ // child -> parent: once-per-minute encoded debug snapshot from inside an iframe
4134
+ iframeDebug: 'iframe debug snapshot',
3988
4135
  // checking updates
3989
4136
  polling: 'hello-how-are-you-im-under-the-water-please-help-me',
3990
4137
  // happens if tab is old and has outdated token but
3991
4138
  // not communicating with backend to update it (for whatever reason)
3992
4139
  reset: 'reset-your-session-please',
3993
4140
  };
4141
+ /** reverse map proto value -> short readable key, for the crossdomain debug log */
4142
+ const protoLabel = Object.fromEntries(Object.entries(proto).map(([k, v]) => [v, k]));
3994
4143
  class App {
3995
4144
  get tagMatcher() {
3996
4145
  return this.tagWatcher.matcher;
@@ -3998,6 +4147,8 @@ class App {
3998
4147
  constructor(projectKey, sessionToken, options, signalError, insideIframe) {
3999
4148
  this.signalError = signalError;
4000
4149
  this.insideIframe = insideIframe;
4150
+ // Registered by input/img/canvas to re-emit a node when its level changes.
4151
+ this.resanitizeCallbacks = [];
4001
4152
  this.messages = [];
4002
4153
  /**
4003
4154
  * we need 2 buffers, so we don't lose anything
@@ -4009,7 +4160,7 @@ class App {
4009
4160
  this.stopCallbacks = [];
4010
4161
  this.commitCallbacks = [];
4011
4162
  this.activityState = ActivityState.NotActive;
4012
- this.version = '17.2.9'; // TODO: version compatability check inside each plugin.
4163
+ this.version = '17.2.11'; // TODO: version compatability check inside each plugin.
4013
4164
  this.socketMode = false;
4014
4165
  this.compressionThreshold = 24 * 1000;
4015
4166
  this.bc = null;
@@ -4026,10 +4177,64 @@ class App {
4026
4177
  this.checkStatus = () => {
4027
4178
  return this.parentActive;
4028
4179
  };
4180
+ /** child-side crossdomain debug state (only meaningful when insideIframe) */
4181
+ this.lastTokenReceived = null;
4182
+ this.lastParentMsgAt = 0;
4183
+ this.lastSentToParentAt = 0;
4184
+ this.iframeDebugInterval = null;
4185
+ /**
4186
+ * Child-side counterpart of emitCrossdomainDebug: once per minute an iframe posts an
4187
+ * encoded snapshot of its own tracking state up to the parent, which records it as a
4188
+ * console log. Posted directly (not via this.send) so it is reported even when the
4189
+ * child is NOT active — an inactive/orphaned child is exactly what we want to catch.
4190
+ */
4191
+ this.emitIframeDebug = () => {
4192
+ if (!this.insideIframe || !this.options.crossdomain?.enabled)
4193
+ return;
4194
+ const now = Date.now();
4195
+ const rel = (t) => (t ? now - t : null);
4196
+ const payload = {
4197
+ ctx: this.contextId,
4198
+ active: this.active(),
4199
+ state: ActivityState[this.activityState],
4200
+ parentActive: this.parentActive,
4201
+ rootId: this.rootId,
4202
+ frameOrder: this.frameOderNumber,
4203
+ // when and what token we last received from the parent (token truncated)
4204
+ token: this.lastTokenReceived
4205
+ ? { tok: this.lastTokenReceived.tok, agoMs: now - this.lastTokenReceived.at }
4206
+ : null,
4207
+ // last two-way communication with the parent
4208
+ lastParentMsgAgoMs: rel(this.lastParentMsgAt),
4209
+ lastSentToParentAgoMs: rel(this.lastSentToParentAt),
4210
+ };
4211
+ const json = JSON.stringify(payload);
4212
+ let encoded;
4213
+ try {
4214
+ encoded = btoa(json);
4215
+ }
4216
+ catch {
4217
+ encoded = json;
4218
+ }
4219
+ try {
4220
+ window.parent.postMessage({ line: proto.iframeDebug, context: this.contextId, debug: encoded }, this.options.crossdomain?.parentDomain ?? '*');
4221
+ this.markSentToParent();
4222
+ }
4223
+ catch (e) {
4224
+ this.debug.error('iframe debug post failed', e);
4225
+ }
4226
+ };
4029
4227
  this.parentCrossDomainFrameListener = (event) => {
4030
4228
  const { data } = event;
4031
4229
  if (!data || event.source === window)
4032
4230
  return;
4231
+ // Debug: remember the last time the parent talked to us.
4232
+ if (data.line === proto.startIframe ||
4233
+ data.line === proto.parentAlive ||
4234
+ data.line === proto.iframeId ||
4235
+ data.line === proto.killIframe) {
4236
+ this.lastParentMsgAt = Date.now();
4237
+ }
4033
4238
  if (data.line === proto.startIframe) {
4034
4239
  // Avoid corrupting an in-flight start; let it complete.
4035
4240
  if (this.activityState === ActivityState.Starting)
@@ -4040,6 +4245,7 @@ class App {
4040
4245
  }
4041
4246
  if (data.token) {
4042
4247
  this.session.setSessionToken(data.token, this.projectKey);
4248
+ this.lastTokenReceived = { tok: String(data.token).slice(-8), at: Date.now() };
4043
4249
  }
4044
4250
  if (data.id !== undefined) {
4045
4251
  this.rootId = data.id;
@@ -4058,6 +4264,7 @@ class App {
4058
4264
  this.parentActive = true;
4059
4265
  this.rootId = data.id;
4060
4266
  this.session.setSessionToken(data.token, this.projectKey);
4267
+ this.lastTokenReceived = { tok: String(data.token).slice(-8), at: Date.now() };
4061
4268
  this.frameOderNumber = data.frameOrderNumber;
4062
4269
  this.frameLevel = data.frameLevel;
4063
4270
  this.debug.log('starting iframe tracking', data);
@@ -4070,14 +4277,110 @@ class App {
4070
4277
  }
4071
4278
  };
4072
4279
  this.trackedFrames = [];
4280
+ /** every context that has been enrolled at least once, to tell an orphan (re-adopt) apart
4281
+ * from a brand-new child still mid-enrollment (leave alone). */
4282
+ this.everTrackedFrames = new Set();
4073
4283
  this.frameLastSeen = new Map();
4284
+ /** crossdomain debug diagnostics, reported once per minute as an encoded console log */
4285
+ this.frameOrigin = new Map();
4286
+ this.frameAnyLastSeen = new Map();
4287
+ this.frameBatchLastSeen = new Map();
4288
+ this.frameLastSent = new Map();
4289
+ this.xdomainDebugInterval = null;
4290
+ /** last time we re-adopted a given orphaned context, to avoid restart spam */
4291
+ this.reAdoptCooldown = new Map();
4292
+ this.RE_ADOPT_COOLDOWN_MS = 2000;
4293
+ /**
4294
+ * Stable, collision-free frame-order allocation. Node ids are partitioned by
4295
+ * (frameLevel, frameOrder) via pack() — every (level, order) owns its own id block, so
4296
+ * two simultaneously-live frames sharing an order at the same level corrupt each other's
4297
+ * node trees and one stops rendering. The previous `trackedFrames.findIndex+1` derived
4298
+ * order from a mutable array index, and pruneStaleFrames()'s .filter() shifts those
4299
+ * indices, so a newly enrolled frame could be handed an order still in use by a live
4300
+ * (but pruned) frame. We instead assign each context a persistent order, unique among all
4301
+ * non-recycled contexts at its level, freed only when the context is GC'd (truly gone).
4302
+ */
4303
+ this.frameAlloc = new Map();
4304
+ this.usedOrdersByLevel = new Map();
4074
4305
  this.FRAME_STALE_MS = 1500;
4306
+ /**
4307
+ * Once per minute: emit an encoded console log from the parent tracker describing every
4308
+ * tracked child iframe and the freshness of our two-way communication with it. Lets us
4309
+ * see in replay which crossdomain iframe went silent and on which leg of the handshake.
4310
+ */
4311
+ /** drop debug entries for contexts we have neither heard from nor messaged in this long */
4312
+ this.XDOMAIN_DEBUG_RETENTION_MS = 10 * 60000;
4313
+ this.emitCrossdomainDebug = () => {
4314
+ if (this.insideIframe || !this.options.crossdomain?.enabled || !this.active())
4315
+ return;
4316
+ const now = Date.now();
4317
+ const rel = (t) => (t === undefined ? null : now - t);
4318
+ // Report the union of currently-tracked frames and every context we have any debug
4319
+ // record for: a frame that broke and stopped polling gets pruned from trackedFrames,
4320
+ // but it is exactly the one we want to surface (with a large lastAnyMsgAgoMs).
4321
+ const tracked = new Set(this.trackedFrames);
4322
+ const contexts = new Set([
4323
+ ...this.trackedFrames,
4324
+ ...this.frameAnyLastSeen.keys(),
4325
+ ...this.frameLastSent.keys(),
4326
+ ]);
4327
+ const frames = Array.from(contexts).map((ctx, i) => {
4328
+ const sent = this.frameLastSent.get(ctx);
4329
+ const alloc = this.frameAlloc.get(ctx);
4330
+ return {
4331
+ // the actual allocated (level, order) node-id partition, else an enumeration index
4332
+ n: alloc ? alloc.order : i + 1,
4333
+ level: alloc ? alloc.level : null,
4334
+ // identify by domain if we have it, otherwise the context id, otherwise the number
4335
+ id: this.frameOrigin.get(ctx) || ctx || `#${i + 1}`,
4336
+ tracked: tracked.has(ctx),
4337
+ lastAnyMsgAgoMs: rel(this.frameAnyLastSeen.get(ctx)),
4338
+ lastBatchAgoMs: rel(this.frameBatchLastSeen.get(ctx)),
4339
+ lastSent: sent ? { line: sent.line, agoMs: now - sent.t } : null,
4340
+ };
4341
+ });
4342
+ // GC: forget contexts that have been silent and un-messaged past the retention window.
4343
+ const cutoff = now - this.XDOMAIN_DEBUG_RETENTION_MS;
4344
+ for (const ctx of contexts) {
4345
+ if (tracked.has(ctx))
4346
+ continue;
4347
+ const seen = this.frameAnyLastSeen.get(ctx) ?? 0;
4348
+ const sentT = this.frameLastSent.get(ctx)?.t ?? 0;
4349
+ if (Math.max(seen, sentT) < cutoff) {
4350
+ this.frameOrigin.delete(ctx);
4351
+ this.frameAnyLastSeen.delete(ctx);
4352
+ this.frameBatchLastSeen.delete(ctx);
4353
+ this.frameLastSent.delete(ctx);
4354
+ this.reAdoptCooldown.delete(ctx);
4355
+ this.everTrackedFrames.delete(ctx);
4356
+ this.freeFrameOrder(ctx);
4357
+ }
4358
+ }
4359
+ const payload = { t: now, count: frames.length, frames };
4360
+ const json = JSON.stringify(payload);
4361
+ let encoded;
4362
+ try {
4363
+ // payload is ASCII (base36 contexts, URL origins, numbers), so plain base64 is safe
4364
+ encoded = btoa(json);
4365
+ }
4366
+ catch {
4367
+ encoded = json;
4368
+ }
4369
+ this.send(ConsoleLog('info', `[OR_XDOMAIN_DEBUG] ${encoded}`));
4370
+ };
4075
4371
  this.crossDomainIframeListener = (event) => {
4076
4372
  if (event.source === window)
4077
4373
  return;
4078
4374
  const { data } = event;
4079
4375
  if (!data)
4080
4376
  return;
4377
+ // Debug: remember when we last heard *anything* from this context, and its domain.
4378
+ if (data.context) {
4379
+ this.frameAnyLastSeen.set(data.context, Date.now());
4380
+ if (event.origin && !this.frameOrigin.has(data.context)) {
4381
+ this.frameOrigin.set(data.context, event.origin);
4382
+ }
4383
+ }
4081
4384
  // Record liveness regardless of our own active state so the queue can prune
4082
4385
  // stale contexts reliably once we resume dispatching commands after a cycle.
4083
4386
  if ((data.line === proto.polling || data.line === proto.iframeSignal) && data.context) {
@@ -4085,9 +4388,15 @@ class App {
4085
4388
  }
4086
4389
  if (!this.active())
4087
4390
  return;
4391
+ if (data.line === proto.iframeDebug) {
4392
+ // A child posted its once-per-minute snapshot; surface it in our recorded console.
4393
+ this.send(ConsoleLog('info', `[OR_XDOMAIN_IFRAME_DEBUG] ${data.debug}`));
4394
+ return;
4395
+ }
4088
4396
  if (data.line === proto.iframeSignal) {
4089
4397
  // @ts-ignore
4090
4398
  event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*');
4399
+ this.recordSentToFrame(data.context, proto.parentAlive);
4091
4400
  const signalId = async () => {
4092
4401
  if (event.source === null) {
4093
4402
  return console.error('Couldnt connect to event.source for child iframe tracking');
@@ -4104,22 +4413,25 @@ class App {
4104
4413
  else {
4105
4414
  this.trackedFrames.push(data.context);
4106
4415
  }
4416
+ this.everTrackedFrames.add(data.context);
4107
4417
  await this.waitStarted();
4108
4418
  const token = this.session.getSessionToken(this.projectKey);
4109
- const order = this.trackedFrames.findIndex((f) => f === data.context) + 1;
4110
- if (order === 0) {
4111
- this.debug.error('Couldnt get order number for iframe', data.context, this.trackedFrames);
4112
- }
4419
+ // Persistent, collision-free order (NOT the shifting array index). A restart of the
4420
+ // same context keeps its order/id-block for continuity; distinct live frames at the
4421
+ // same level never share one.
4422
+ const frameLevel = this.frameLevel + 1;
4423
+ const order = this.allocateFrameOrder(data.context, frameLevel);
4113
4424
  const iframeData = {
4114
4425
  line: proto.iframeId,
4115
4426
  id,
4116
4427
  token,
4117
4428
  frameOrderNumber: order,
4118
- frameLevel: this.frameLevel + 1,
4429
+ frameLevel,
4119
4430
  };
4120
4431
  this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData);
4121
4432
  // @ts-ignore
4122
4433
  event.source?.postMessage(iframeData, '*');
4434
+ this.recordSentToFrame(data.context, proto.iframeId);
4123
4435
  }
4124
4436
  catch (e) {
4125
4437
  console.error(e);
@@ -4132,6 +4444,9 @@ class App {
4132
4444
  * plus we rewrite some of the messages to be relative to the main context/window
4133
4445
  * */
4134
4446
  if (data.line === proto.iframeBatch) {
4447
+ if (data.context) {
4448
+ this.frameBatchLastSeen.set(data.context, Date.now());
4449
+ }
4135
4450
  const msgBatch = data.messages;
4136
4451
  const mappedMessages = [];
4137
4452
  msgBatch.forEach((msg) => {
@@ -4179,6 +4494,16 @@ class App {
4179
4494
  this.messages.push(...mappedMessages);
4180
4495
  }
4181
4496
  if (data.line === proto.polling) {
4497
+ // Self-heal: a live child that was enrolled before but fell out of trackedFrames
4498
+ // (pruned during a stop/start gap) keeps polling yet never re-signals. Re-adopt it
4499
+ // so it restarts and re-enrolls. We require everTrackedFrames so a brand-new child
4500
+ // still mid-enrollment (iframeSignal/checkNodeId in flight) is left alone.
4501
+ if (data.context &&
4502
+ this.everTrackedFrames.has(data.context) &&
4503
+ !this.trackedFrames.includes(data.context)) {
4504
+ this.reAdoptOrphanFrame(event, data.context);
4505
+ return;
4506
+ }
4182
4507
  if (!this.pollingQueue.order.length) {
4183
4508
  return;
4184
4509
  }
@@ -4215,6 +4540,7 @@ class App {
4215
4540
  }
4216
4541
  // @ts-ignore
4217
4542
  event.source?.postMessage(message, '*');
4543
+ this.recordSentToFrame(data.context, nextCommand);
4218
4544
  if (this.pollingQueue[nextCommand].length === 0) {
4219
4545
  delete this.pollingQueue[nextCommand];
4220
4546
  this.pollingQueue.order.shift();
@@ -4252,6 +4578,7 @@ class App {
4252
4578
  source: thisTab,
4253
4579
  context: this.contextId,
4254
4580
  }, this.options.crossdomain?.parentDomain ?? '*');
4581
+ this.markSentToParent();
4255
4582
  /**
4256
4583
  * since we need to wait uncertain amount of time
4257
4584
  * and I don't want to have recursion going on,
@@ -4272,6 +4599,7 @@ class App {
4272
4599
  source: thisTab,
4273
4600
  context: this.contextId,
4274
4601
  }, this.options.crossdomain?.parentDomain ?? '*');
4602
+ this.markSentToParent();
4275
4603
  this.debug.info('Trying to signal to parent, attempt:', retries + 1);
4276
4604
  retries++;
4277
4605
  };
@@ -4363,6 +4691,26 @@ class App {
4363
4691
  this.restartCanvasTracking = () => {
4364
4692
  this.canvasRecorder?.restartTracking();
4365
4693
  };
4694
+ this.attachResanitizeCallback = (cb) => {
4695
+ this.resanitizeCallbacks.push(cb);
4696
+ };
4697
+ this.callResanitizeCallbacks = (node, id) => {
4698
+ this.resanitizeCallbacks.forEach((cb) => cb(node, id));
4699
+ };
4700
+ this.resanitize = (el) => {
4701
+ const root = el ?? (IN_BROWSER ? document.documentElement : undefined);
4702
+ if (!root) {
4703
+ return;
4704
+ }
4705
+ this.observer.resanitizeSubtree(root);
4706
+ };
4707
+ this.checkSanitization = (el) => {
4708
+ const id = this.nodes.getID(el);
4709
+ if (id === undefined) {
4710
+ return undefined;
4711
+ }
4712
+ return this.sanitizer.getLevel(id);
4713
+ };
4366
4714
  this.flushBuffer = async (buffer) => {
4367
4715
  return new Promise((res, reject) => {
4368
4716
  if (buffer.length === 0) {
@@ -4488,7 +4836,13 @@ class App {
4488
4836
  line: proto.polling,
4489
4837
  context: this.contextId,
4490
4838
  }, options.crossdomain?.parentDomain ?? '*');
4839
+ this.markSentToParent();
4491
4840
  }, 250);
4841
+ // Child-only: once per minute, post an encoded snapshot of our own tracking state
4842
+ // (active?, token received, last comms) up to the parent so it lands in the replay.
4843
+ if (this.iframeDebugInterval)
4844
+ clearInterval(this.iframeDebugInterval);
4845
+ this.iframeDebugInterval = setInterval(this.emitIframeDebug, 60000);
4492
4846
  }
4493
4847
  else {
4494
4848
  this.initWorker();
@@ -4497,6 +4851,13 @@ class App {
4497
4851
  * so they can act as if it was just a same-domain iframe
4498
4852
  * */
4499
4853
  window.addEventListener('message', this.crossDomainIframeListener);
4854
+ // Parent-only: once per minute, log an encoded snapshot of every tracked child
4855
+ // iframe and the freshness of our two-way comms, to debug iframes that go silent.
4856
+ if (this.options.crossdomain?.enabled) {
4857
+ if (this.xdomainDebugInterval)
4858
+ clearInterval(this.xdomainDebugInterval);
4859
+ this.xdomainDebugInterval = setInterval(this.emitCrossdomainDebug, 60000);
4860
+ }
4500
4861
  }
4501
4862
  if (this.bc !== null) {
4502
4863
  this.bc.postMessage({
@@ -4546,6 +4907,62 @@ class App {
4546
4907
  };
4547
4908
  }
4548
4909
  }
4910
+ /** stamp every outbound post to the parent window, for the child debug snapshot */
4911
+ markSentToParent() {
4912
+ this.lastSentToParentAt = Date.now();
4913
+ }
4914
+ allocateFrameOrder(ctx, level) {
4915
+ const existing = this.frameAlloc.get(ctx);
4916
+ if (existing !== undefined)
4917
+ return existing.order;
4918
+ let used = this.usedOrdersByLevel.get(level);
4919
+ if (!used) {
4920
+ used = new Set();
4921
+ this.usedOrdersByLevel.set(level, used);
4922
+ }
4923
+ let order = -1;
4924
+ for (let n = 1; n <= MASK_ORDER; n++) {
4925
+ if (!used.has(n)) {
4926
+ order = n;
4927
+ break;
4928
+ }
4929
+ }
4930
+ if (order === -1) {
4931
+ // Overflow (>127 live frames at one level): evict the least-recently-seen context at
4932
+ // this level that is not currently tracked, and reuse its slot rather than failing.
4933
+ let lru = null;
4934
+ let lruSeen = Infinity;
4935
+ const trackedSet = new Set(this.trackedFrames);
4936
+ this.frameAlloc.forEach((alloc, c) => {
4937
+ if (alloc.level !== level || trackedSet.has(c))
4938
+ return;
4939
+ const seen = this.frameAnyLastSeen.get(c) ?? 0;
4940
+ if (seen < lruSeen) {
4941
+ lruSeen = seen;
4942
+ lru = c;
4943
+ }
4944
+ });
4945
+ if (lru !== null) {
4946
+ order = this.frameAlloc.get(lru).order;
4947
+ this.frameAlloc.delete(lru);
4948
+ this.debug.error('OR: frame order space exhausted, evicting', lru, 'for', ctx);
4949
+ }
4950
+ else {
4951
+ order = MASK_ORDER;
4952
+ this.debug.error('OR: frame order overflow, reusing max order for', ctx);
4953
+ }
4954
+ }
4955
+ used.add(order);
4956
+ this.frameAlloc.set(ctx, { order, level });
4957
+ return order;
4958
+ }
4959
+ freeFrameOrder(ctx) {
4960
+ const alloc = this.frameAlloc.get(ctx);
4961
+ if (!alloc)
4962
+ return;
4963
+ this.frameAlloc.delete(ctx);
4964
+ this.usedOrdersByLevel.get(alloc.level)?.delete(alloc.order);
4965
+ }
4549
4966
  pruneStaleFrames() {
4550
4967
  const staleAfter = Date.now() - this.FRAME_STALE_MS;
4551
4968
  this.trackedFrames = this.trackedFrames.filter((ctx) => {
@@ -4556,6 +4973,45 @@ class App {
4556
4973
  return false;
4557
4974
  });
4558
4975
  }
4976
+ /** records the last command/signal we posted to a given child iframe context (debug) */
4977
+ recordSentToFrame(ctx, line) {
4978
+ if (!ctx)
4979
+ return;
4980
+ this.frameLastSent.set(ctx, { line: protoLabel[line] ?? line, t: Date.now() });
4981
+ }
4982
+ /**
4983
+ * Self-heal for the "kill-then-prune orphan" race: a live child can fall out of
4984
+ * `trackedFrames` (its 250ms poll was delayed past FRAME_STALE_MS during the parent's
4985
+ * stop/start NotActive gap, so pruneStaleFrames evicted it). It keeps polling but the
4986
+ * only re-enrollment path is an `iframeSignal`, which a stopped/active-but-orphaned
4987
+ * child never re-emits — so it would record nothing forever. When we (the parent) are
4988
+ * active and see a poll from an un-tracked context, push a `startIframe` so the child
4989
+ * restarts, re-runs the full handshake and re-observes with a fresh rootId. Cooldowned
4990
+ * so we don't spam restarts during the child's start window.
4991
+ */
4992
+ reAdoptOrphanFrame(event, ctx) {
4993
+ const now = Date.now();
4994
+ const last = this.reAdoptCooldown.get(ctx) ?? 0;
4995
+ if (now - last < this.RE_ADOPT_COOLDOWN_MS)
4996
+ return;
4997
+ this.reAdoptCooldown.set(ctx, now);
4998
+ const message = {
4999
+ line: proto.startIframe,
5000
+ token: this.session.getSessionToken(this.projectKey),
5001
+ };
5002
+ const targetFrame = this.pageFrames.find((f) => f.contentWindow === event.source) ||
5003
+ Array.from(document.querySelectorAll('iframe')).find((f) => f.contentWindow === event.source);
5004
+ if (targetFrame) {
5005
+ const nodeId = targetFrame[this.options.node_id];
5006
+ if (nodeId !== undefined) {
5007
+ message.id = nodeId;
5008
+ }
5009
+ }
5010
+ // @ts-ignore
5011
+ event.source?.postMessage(message, '*');
5012
+ this.recordSentToFrame(ctx, proto.startIframe);
5013
+ this.debug.log('Re-adopting orphaned crossdomain iframe', ctx);
5014
+ }
4559
5015
  allowAppStart() {
4560
5016
  this.canStart = true;
4561
5017
  if (this.startTimeout) {
@@ -4710,7 +5166,9 @@ class App {
4710
5166
  window.parent.postMessage({
4711
5167
  line: proto.iframeBatch,
4712
5168
  messages: this.messages,
5169
+ context: this.contextId,
4713
5170
  }, this.options.crossdomain?.parentDomain ?? '*');
5171
+ this.markSentToParent();
4714
5172
  this.commitCallbacks.forEach((cb) => cb(this.messages));
4715
5173
  this.messages.length = 0;
4716
5174
  return;
@@ -5910,6 +6368,12 @@ function Img (app) {
5910
6368
  sendImgAttrs(node);
5911
6369
  observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
5912
6370
  });
6371
+ // On a runtime level change, re-evaluate placeholder vs real src for this image.
6372
+ app.attachResanitizeCallback((node) => {
6373
+ if (hasTag(node, 'img')) {
6374
+ sendImgAttrs(node);
6375
+ }
6376
+ });
5913
6377
  }
5914
6378
 
5915
6379
  const INPUT_TYPES = [
@@ -6039,9 +6503,11 @@ function Input (app, opts) {
6039
6503
  }
6040
6504
  const inputValues = new Map();
6041
6505
  const checkboxValues = new Map();
6506
+ const selectValues = new Map();
6042
6507
  app.attachStopCallback(() => {
6043
6508
  inputValues.clear();
6044
6509
  checkboxValues.clear();
6510
+ selectValues.clear();
6045
6511
  tagSelectorMap.clear();
6046
6512
  });
6047
6513
  function trackInputValue(id, node) {
@@ -6058,6 +6524,13 @@ function Input (app, opts) {
6058
6524
  checkboxValues.set(id, value);
6059
6525
  app.send(SetInputChecked(id, value));
6060
6526
  }
6527
+ function trackSelectValue(id, node) {
6528
+ if (selectValues.get(id) === node.value) {
6529
+ return;
6530
+ }
6531
+ selectValues.set(id, node.value);
6532
+ sendInputValue(id, node);
6533
+ }
6061
6534
  // The only way (to our knowledge) to track all kinds of input changes, including those made by JS
6062
6535
  app.ticker.attach(() => {
6063
6536
  inputValues.forEach((value, id) => {
@@ -6072,6 +6545,12 @@ function Input (app, opts) {
6072
6545
  return checkboxValues.delete(id);
6073
6546
  trackCheckboxValue(id, node.checked);
6074
6547
  });
6548
+ selectValues.forEach((_, id) => {
6549
+ const node = app.nodes.getNode(id);
6550
+ if (!node)
6551
+ return selectValues.delete(id);
6552
+ trackSelectValue(id, node);
6553
+ });
6075
6554
  }, 3);
6076
6555
  function sendInputChange(id, node, hesitationTime, inputTime) {
6077
6556
  const { value, mask } = getInputValue(id, node);
@@ -6081,6 +6560,13 @@ function Input (app, opts) {
6081
6560
  }
6082
6561
  app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime));
6083
6562
  }
6563
+ // Re-emit a field's value when its sanitization level changes at runtime.
6564
+ // getInputValue() reads the current level, so re-sending applies the new mask.
6565
+ app.attachResanitizeCallback((node, id) => {
6566
+ if (isTextFieldElement(node) || hasTag(node, 'select')) {
6567
+ sendInputValue(id, node);
6568
+ }
6569
+ });
6084
6570
  app.nodes.attachNodeCallback(app.safe((node) => {
6085
6571
  const id = app.nodes.getID(node);
6086
6572
  if (id === undefined) {
@@ -6088,8 +6574,8 @@ function Input (app, opts) {
6088
6574
  }
6089
6575
  // TODO: support multiple select (?): use selectedOptions;
6090
6576
  if (hasTag(node, 'select')) {
6091
- sendInputValue(id, node);
6092
- app.nodes.attachNodeListener(node, 'change', () => sendInputValue(id, node));
6577
+ trackSelectValue(id, node);
6578
+ app.nodes.attachNodeListener(node, 'change', () => trackSelectValue(id, node));
6093
6579
  }
6094
6580
  if (isTextFieldElement(node)) {
6095
6581
  trackInputValue(id, node);
@@ -7350,7 +7836,7 @@ class NetworkMessage {
7350
7836
  return null;
7351
7837
  const gqlHeader = "application/graphql-response";
7352
7838
  const isGraphql = messageInfo.url.includes("/graphql")
7353
- || Object.values(messageInfo.request.headers).some(v => v.includes(gqlHeader));
7839
+ || Object.values(messageInfo.request.headers).some(v => v && typeof v === 'string' && v.includes(gqlHeader));
7354
7840
  if (isGraphql && messageInfo.response.body && typeof messageInfo.response.body === 'string') {
7355
7841
  const isError = messageInfo.response.body.includes("errors");
7356
7842
  messageInfo.status = isError ? 400 : 200;
@@ -7454,6 +7940,7 @@ const genStringBody = (body) => {
7454
7940
  }
7455
7941
  else if (body instanceof Blob ||
7456
7942
  body instanceof ReadableStream ||
7943
+ ArrayBuffer.isView(body) ||
7457
7944
  body instanceof ArrayBuffer) {
7458
7945
  result = 'byte data';
7459
7946
  }
@@ -8942,7 +9429,7 @@ class ConstantProperties {
8942
9429
  user_id: this.user_id,
8943
9430
  distinct_id: this.deviceId,
8944
9431
  sdk_edition: 'web',
8945
- sdk_version: '17.2.9',
9432
+ sdk_version: '17.2.11',
8946
9433
  timezone: getUTCOffsetString(),
8947
9434
  search_engine: this.searchEngine,
8948
9435
  };
@@ -9644,7 +10131,7 @@ class API {
9644
10131
  this.signalStartIssue = (reason, missingApi) => {
9645
10132
  const doNotTrack = this.checkDoNotTrack();
9646
10133
  console.log("Tracker couldn't start due to:", JSON.stringify({
9647
- trackerVersion: '17.2.9',
10134
+ trackerVersion: '17.2.11',
9648
10135
  projectKey: this.options.projectKey,
9649
10136
  doNotTrack,
9650
10137
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
@@ -9656,6 +10143,31 @@ class API {
9656
10143
  }
9657
10144
  this.app.restartCanvasTracking();
9658
10145
  };
10146
+ /**
10147
+ * Re-evaluates sanitization against the current DOM and re-emits whatever
10148
+ * changed, updating already-recorded nodes mid-session. Call after toggling
10149
+ * `data-openreplay-*` attributes or after changing whatever your `domSanitizer`
10150
+ * keys on (class/id/etc).
10151
+ *
10152
+ * @param el - the highest node you changed; omit to re-scan the whole document;
10153
+ * scanning the entire doc is O(dom size)
10154
+ * */
10155
+ this.resanitize = (el) => {
10156
+ if (this.app === null) {
10157
+ return;
10158
+ }
10159
+ this.app.resanitize(el);
10160
+ };
10161
+ /**
10162
+ * Returns the sanitization level the tracker currently has for a node
10163
+ * (0 = Plain, 1 = Obscured, 2 = Hidden), or undefined if it isn't tracked.
10164
+ * */
10165
+ this.checkSanitization = (el) => {
10166
+ if (this.app === null) {
10167
+ return undefined;
10168
+ }
10169
+ return this.app.checkSanitization(el);
10170
+ };
9659
10171
  this.getSessionURL = (options) => {
9660
10172
  if (this.app === null) {
9661
10173
  return undefined;
@@ -9669,7 +10181,10 @@ class API {
9669
10181
  }
9670
10182
  };
9671
10183
  this.identify = this.setUserID;
9672
- this.track = this.analytics?.track;
10184
+ // Delegates at call time: `this.analytics` is assigned in the constructor body,
10185
+ // which runs AFTER field initializers, so binding it here directly would always
10186
+ // capture `undefined`.
10187
+ this.track = (eventName, properties, options) => this.analytics?.track(eventName, properties, options);
9673
10188
  this.userID = (id) => {
9674
10189
  deprecationWarn("'userID' method", "'setUserID' method", '/');
9675
10190
  this.setUserID(id);
@@ -10052,12 +10567,20 @@ class TrackerSingleton {
10052
10567
  constructor() {
10053
10568
  this.instance = null;
10054
10569
  this.isConfigured = false;
10570
+ this.setUserID = (id) => {
10571
+ if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10572
+ return;
10573
+ }
10574
+ this.instance.setUserID(id);
10575
+ };
10055
10576
  this.identify = this.setUserID;
10056
10577
  this.track = (eventName, properties, options) => {
10057
10578
  if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10058
10579
  return;
10059
10580
  }
10060
- this.instance.track?.(eventName, properties);
10581
+ // Route through analytics directly: Tracker.track is bound to analytics?.track
10582
+ // at field-init time (before analytics exists), so it is always undefined.
10583
+ this.instance.analytics?.track(eventName, properties, options);
10061
10584
  };
10062
10585
  }
10063
10586
  /**
@@ -10087,11 +10610,16 @@ class TrackerSingleton {
10087
10610
  if (!IN_BROWSER) {
10088
10611
  return Promise.resolve({ success: false, reason: 'Not in browser environment' });
10089
10612
  }
10090
- if (!this.ensureConfigured()) {
10613
+ if (!this.ensureConfigured() || !this.instance) {
10091
10614
  return Promise.resolve({ success: false, reason: 'Tracker not configured' });
10092
10615
  }
10093
- return (this.instance?.start(startOpts) ||
10094
- Promise.resolve({ success: false, reason: 'Tracker not initialized' }));
10616
+ // Tracker.start() rejects (instead of resolving {success:false}) when the
10617
+ // underlying app failed to initialise (non-https, missing api, doNotTrack,
10618
+ // already initialised...). Normalize so callers always get {success, reason}.
10619
+ return this.instance.start(startOpts).catch((reason) => ({
10620
+ success: false,
10621
+ reason: typeof reason === 'string' ? reason : String(reason),
10622
+ }));
10095
10623
  }
10096
10624
  /**
10097
10625
  * Stop the session and return sessionHash
@@ -10103,21 +10631,9 @@ class TrackerSingleton {
10103
10631
  }
10104
10632
  return this.instance.stop();
10105
10633
  }
10106
- setUserID(id) {
10107
- if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10108
- return;
10109
- }
10110
- this.instance.setUserID(id);
10111
- }
10112
10634
  get analytics() {
10113
- if (this.instance?.analytics) {
10114
- return this.instance.analytics;
10115
- }
10116
- else {
10117
- return null;
10118
- }
10635
+ return this.instance?.analytics ?? null;
10119
10636
  }
10120
- ;
10121
10637
  /**
10122
10638
  * Set metadata for the current session
10123
10639
  *
@@ -10292,6 +10808,51 @@ class TrackerSingleton {
10292
10808
  }
10293
10809
  return this.instance.getTabId();
10294
10810
  }
10811
+ /**
10812
+ * Re-evaluates sanitization against the current DOM and re-emits whatever
10813
+ * changed, updating already-recorded nodes mid-session. Call after toggling
10814
+ * `data-openreplay-*` attributes or after changing whatever your `domSanitizer`
10815
+ * keys on (class/id/etc).
10816
+ *
10817
+ * @param el - the highest node you changed; omit to re-scan the whole document.
10818
+ * */
10819
+ resanitize(el) {
10820
+ if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10821
+ return;
10822
+ }
10823
+ return this.instance.resanitize(el);
10824
+ }
10825
+ /**
10826
+ * Returns the sanitization level the tracker currently has for a node
10827
+ * (0 = Plain, 1 = Obscured, 2 = Hidden), or undefined if it isn't tracked.
10828
+ * */
10829
+ checkSanitization(el) {
10830
+ if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10831
+ return undefined;
10832
+ }
10833
+ return this.instance.checkSanitization(el);
10834
+ }
10835
+ incident(options) {
10836
+ if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10837
+ return;
10838
+ }
10839
+ this.instance.incident(options);
10840
+ }
10841
+ /**
10842
+ * Use custom token for analytics events without session recording
10843
+ * */
10844
+ setAnalyticsToken(token) {
10845
+ if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10846
+ return;
10847
+ }
10848
+ this.instance.setAnalyticsToken(token);
10849
+ }
10850
+ getAnalyticsToken() {
10851
+ if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
10852
+ return undefined;
10853
+ }
10854
+ return this.instance.getAnalyticsToken();
10855
+ }
10295
10856
  }
10296
10857
  const tracker = new TrackerSingleton();
10297
10858