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