@openreplay/tracker 18.0.11 → 18.0.13-beta.0

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
@@ -2340,8 +2340,9 @@ class Nodes {
2340
2340
  this.nextNodeId = this.createFrameId(level, frameOrder);
2341
2341
  }
2342
2342
  registerNode(node) {
2343
- let id = node[this.node_id];
2344
- const isNew = id === undefined;
2343
+ const existing = node[this.node_id];
2344
+ const isNew = existing === undefined || this.nodes.get(existing) !== node;
2345
+ let id = existing;
2345
2346
  if (isNew) {
2346
2347
  id = this.nextNodeId;
2347
2348
  this.totalNodeAmount++;
@@ -2370,6 +2371,12 @@ class Nodes {
2370
2371
  return undefined;
2371
2372
  return node[this.node_id];
2372
2373
  }
2374
+ isBound(node) {
2375
+ if (!node)
2376
+ return false;
2377
+ const id = node[this.node_id];
2378
+ return id !== undefined && this.nodes.get(id) === node;
2379
+ }
2373
2380
  getNode(id) {
2374
2381
  return this.nodes.get(id);
2375
2382
  }
@@ -2711,9 +2718,15 @@ function ConstructedStyleSheets (app) {
2711
2718
  app.send(AdoptedSSAddOwner(sheetID, nodeID));
2712
2719
  }
2713
2720
  if (init) {
2714
- const rules = s.cssRules;
2715
- for (let i = 0; i < rules.length; i++) {
2716
- app.send(AdoptedSSInsertRuleURLBased(sheetID, rules[i].cssText, i, app.getBaseHref()));
2721
+ try {
2722
+ const rules = s.cssRules;
2723
+ for (let i = 0; i < rules.length; i++) {
2724
+ app.send(AdoptedSSInsertRuleURLBased(sheetID, rules[i].cssText, i, app.getBaseHref()));
2725
+ }
2726
+ }
2727
+ catch (e) {
2728
+ app.debug.log('Couldnt access adopted stylesheet', e);
2729
+ // Skip inaccessible (cross-origin) stylesheet
2717
2730
  }
2718
2731
  }
2719
2732
  nowOwning.push(sheetID);
@@ -2962,46 +2975,58 @@ class Observer {
2962
2975
  this.inlinerOptions = options.inlinerOptions;
2963
2976
  this.observer = createMutationObserver(this.app.safe((mutations) => {
2964
2977
  for (const mutation of mutations) {
2965
- // mutations order is sequential
2966
- const target = mutation.target;
2967
- const type = mutation.type;
2968
- if (!isObservable(target)) {
2969
- continue;
2970
- }
2971
- if (type === 'childList') {
2972
- for (let i = 0; i < mutation.removedNodes.length; i++) {
2973
- // Should be the same as bindTree(mutation.removedNodes[i]), but logic needs to be be untied
2974
- if (isObservable(mutation.removedNodes[i])) {
2975
- this.bindNode(mutation.removedNodes[i]);
2978
+ // THEORY S1: app.safe() wraps this whole callback in a try/catch that
2979
+ // SILENTLY swallows — so a throw while processing one mutation aborts
2980
+ // the entire batch (the single commitNodes() below never runs) and
2981
+ // every pending node in it is lost. Isolating + logging per mutation
2982
+ // both surfaces it into the session and prevents one bad node from
2983
+ // dropping the rest of the batch.
2984
+ try {
2985
+ // mutations order is sequential
2986
+ const target = mutation.target;
2987
+ const type = mutation.type;
2988
+ if (!isObservable(target)) {
2989
+ continue;
2990
+ }
2991
+ if (type === 'childList') {
2992
+ for (let i = 0; i < mutation.removedNodes.length; i++) {
2993
+ // Should be the same as bindTree(mutation.removedNodes[i]), but logic needs to be be untied
2994
+ if (isObservable(mutation.removedNodes[i])) {
2995
+ this.bindNode(mutation.removedNodes[i]);
2996
+ }
2997
+ }
2998
+ for (let i = 0; i < mutation.addedNodes.length; i++) {
2999
+ this.bindTree(mutation.addedNodes[i]);
2976
3000
  }
3001
+ continue;
2977
3002
  }
2978
- for (let i = 0; i < mutation.addedNodes.length; i++) {
2979
- this.bindTree(mutation.addedNodes[i]);
3003
+ const id = this.app.nodes.getID(target);
3004
+ if (id === undefined) {
3005
+ continue;
2980
3006
  }
2981
- continue;
2982
- }
2983
- const id = this.app.nodes.getID(target);
2984
- if (id === undefined) {
2985
- continue;
2986
- }
2987
- if (!this.recents.has(id)) {
2988
- this.recents.set(id, RecentsType.Changed); // TODO only when altered
2989
- }
2990
- if (type === 'attributes') {
2991
- const name = mutation.attributeName;
2992
- if (name === null) {
3007
+ if (!this.recents.has(id)) {
3008
+ this.recents.set(id, RecentsType.Changed); // TODO only when altered
3009
+ }
3010
+ if (type === 'attributes') {
3011
+ const name = mutation.attributeName;
3012
+ if (name === null) {
3013
+ continue;
3014
+ }
3015
+ let attr = this.attributesMap.get(id);
3016
+ if (attr === undefined) {
3017
+ this.attributesMap.set(id, (attr = new Set()));
3018
+ }
3019
+ attr.add(name);
2993
3020
  continue;
2994
3021
  }
2995
- let attr = this.attributesMap.get(id);
2996
- if (attr === undefined) {
2997
- this.attributesMap.set(id, (attr = new Set()));
3022
+ if (type === 'characterData') {
3023
+ this.textSet.add(id);
3024
+ continue;
2998
3025
  }
2999
- attr.add(name);
3000
- continue;
3001
3026
  }
3002
- if (type === 'characterData') {
3003
- this.textSet.add(id);
3004
- continue;
3027
+ catch (mutationErr) {
3028
+ const t = mutation.target;
3029
+ this.orDebug(`mutation processing threw type=${mutation.type} target=<${t.tagName ?? t.nodeName ?? 'unknown'}>: ${mutationErr?.message ?? String(mutationErr)}`);
3005
3030
  }
3006
3031
  }
3007
3032
  this.commitNodes();
@@ -3167,10 +3192,11 @@ class Observer {
3167
3192
  this.bindNode(node);
3168
3193
  const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
3169
3194
  acceptNode: (node) => {
3170
- if (this.app.nodes.getID(node) !== undefined) {
3171
- this.app.debug.info('! Node is already bound', node);
3172
- }
3173
- return isIgnored(node) || this.app.nodes.getID(node) !== undefined
3195
+ // Use the Map-aware isBound() rather than the raw __openreplay_id
3196
+ // property: a node carrying a stale id from a previous observe cycle
3197
+ // must be re-accepted and re-bound here, otherwise the snapshot walk
3198
+ // silently skips it (and its subtree, e.g. a <style>'s CSS text).
3199
+ return isIgnored(node) || this.app.nodes.isBound(node)
3174
3200
  ? NodeFilter.FILTER_REJECT
3175
3201
  : NodeFilter.FILTER_ACCEPT;
3176
3202
  },
@@ -3224,15 +3250,25 @@ class Observer {
3224
3250
  if (parent === null) {
3225
3251
  // Sometimes one observation contains attribute mutations for the removimg node, which gets ignored here.
3226
3252
  // That shouldn't affect the visual rendering ( should it? maybe when transition applied? )
3253
+ // THEORY: an element silently dropped at commit time (no message emitted).
3254
+ if (isElementNode(node)) {
3255
+ this.orDebug(`commit drop <${node.tagName}> reason=no-parent`);
3256
+ }
3227
3257
  this.unbindTree(node);
3228
3258
  return false;
3229
3259
  }
3230
3260
  parentID = this.app.nodes.getID(parent);
3231
3261
  if (parentID === undefined) {
3262
+ if (isElementNode(node)) {
3263
+ this.orDebug(`commit drop <${node.tagName}> reason=parent-unbound parent=<${parent.tagName ?? parent.nodeName}>`);
3264
+ }
3232
3265
  this.unbindTree(node);
3233
3266
  return false;
3234
3267
  }
3235
3268
  if (!this.commitNode(parentID)) {
3269
+ if (isElementNode(node)) {
3270
+ this.orDebug(`commit drop <${node.tagName}> reason=parent-commit-failed`);
3271
+ }
3236
3272
  this.unbindTree(node);
3237
3273
  return false;
3238
3274
  }
@@ -3376,7 +3412,45 @@ class Observer {
3376
3412
  beforeCommit(this.app.nodes.getID(node));
3377
3413
  this.commitNodes(true);
3378
3414
  }
3415
+ /**
3416
+ * [OPENREPLAYDEBUG] Emits a diagnostic line INTO the recorded session's
3417
+ * console (searchable in replay by "[OPENREPLAYDEBUG]"), NOT the local
3418
+ * devtools console — used to test capture-loss theories against real traffic.
3419
+ */
3420
+ orDebug(message) {
3421
+ try {
3422
+ this.app.send(ConsoleLog('warn', `[OPENREPLAYDEBUG] ${message}`));
3423
+ }
3424
+ catch (_) {
3425
+ /* diagnostics must never break recording */
3426
+ }
3427
+ }
3379
3428
  disconnect() {
3429
+ // THEORY S3: a disconnect may discard MutationRecords still queued by the
3430
+ // browser. takeRecords() drains them — they would be discarded by
3431
+ // disconnect() below anyway, so this changes nothing functionally, it only
3432
+ // lets us SEE whether a disconnect strands un-processed DOM additions
3433
+ // (e.g. a freshly appended <head> <style>). NOTE: if this disconnect is part
3434
+ // of stop(), the surrounding buffer may be cleared and this log lost; it is
3435
+ // most reliable for mid-session re-observes (cold-start cycle / iframe).
3436
+ const pending = this.observer.takeRecords();
3437
+ if (pending.length) {
3438
+ let addedEls = 0;
3439
+ const tags = [];
3440
+ for (const m of pending) {
3441
+ for (let i = 0; i < m.addedNodes.length; i++) {
3442
+ const n = m.addedNodes[i];
3443
+ if (n.tagName) {
3444
+ addedEls++;
3445
+ if (tags.length < 10)
3446
+ tags.push(n.tagName);
3447
+ }
3448
+ }
3449
+ }
3450
+ if (addedEls > 0) {
3451
+ this.orDebug(`disconnect stranded ${pending.length} pending record(s), ${addedEls} added element(s): ${tags.join(',')}`);
3452
+ }
3453
+ }
3380
3454
  this.observer.disconnect();
3381
3455
  this.clear();
3382
3456
  this.throttledSetNodeData.clear();
@@ -4024,12 +4098,16 @@ const proto = {
4024
4098
  parentAlive: 'signal that parent is live',
4025
4099
  killIframe: 'stop tracker inside frame',
4026
4100
  startIframe: 'start tracker inside frame',
4101
+ // child -> parent: once-per-minute encoded debug snapshot from inside an iframe
4102
+ iframeDebug: 'iframe debug snapshot',
4027
4103
  // checking updates
4028
4104
  polling: 'hello-how-are-you-im-under-the-water-please-help-me',
4029
4105
  // happens if tab is old and has outdated token but
4030
4106
  // not communicating with backend to update it (for whatever reason)
4031
4107
  reset: 'reset-your-session-please',
4032
4108
  };
4109
+ /** reverse map proto value -> short readable key, for the crossdomain debug log */
4110
+ const protoLabel = Object.fromEntries(Object.entries(proto).map(([k, v]) => [v, k]));
4033
4111
  class App {
4034
4112
  get tagMatcher() {
4035
4113
  return this.tagWatcher.matcher;
@@ -4048,7 +4126,7 @@ class App {
4048
4126
  this.stopCallbacks = [];
4049
4127
  this.commitCallbacks = [];
4050
4128
  this.activityState = ActivityState.NotActive;
4051
- this.version = '18.0.11'; // TODO: version compatability check inside each plugin.
4129
+ this.version = '18.0.13-beta.0'; // TODO: version compatability check inside each plugin.
4052
4130
  this.socketMode = false;
4053
4131
  this.compressionThreshold = 24 * 1000;
4054
4132
  this.bc = null;
@@ -4065,10 +4143,64 @@ class App {
4065
4143
  this.checkStatus = () => {
4066
4144
  return this.parentActive;
4067
4145
  };
4146
+ /** child-side crossdomain debug state (only meaningful when insideIframe) */
4147
+ this.lastTokenReceived = null;
4148
+ this.lastParentMsgAt = 0;
4149
+ this.lastSentToParentAt = 0;
4150
+ this.iframeDebugInterval = null;
4151
+ /**
4152
+ * Child-side counterpart of emitCrossdomainDebug: once per minute an iframe posts an
4153
+ * encoded snapshot of its own tracking state up to the parent, which records it as a
4154
+ * console log. Posted directly (not via this.send) so it is reported even when the
4155
+ * child is NOT active — an inactive/orphaned child is exactly what we want to catch.
4156
+ */
4157
+ this.emitIframeDebug = () => {
4158
+ if (!this.insideIframe || !this.options.crossdomain?.enabled)
4159
+ return;
4160
+ const now = Date.now();
4161
+ const rel = (t) => (t ? now - t : null);
4162
+ const payload = {
4163
+ ctx: this.contextId,
4164
+ active: this.active(),
4165
+ state: ActivityState[this.activityState],
4166
+ parentActive: this.parentActive,
4167
+ rootId: this.rootId,
4168
+ frameOrder: this.frameOderNumber,
4169
+ // when and what token we last received from the parent (token truncated)
4170
+ token: this.lastTokenReceived
4171
+ ? { tok: this.lastTokenReceived.tok, agoMs: now - this.lastTokenReceived.at }
4172
+ : null,
4173
+ // last two-way communication with the parent
4174
+ lastParentMsgAgoMs: rel(this.lastParentMsgAt),
4175
+ lastSentToParentAgoMs: rel(this.lastSentToParentAt),
4176
+ };
4177
+ const json = JSON.stringify(payload);
4178
+ let encoded;
4179
+ try {
4180
+ encoded = btoa(json);
4181
+ }
4182
+ catch {
4183
+ encoded = json;
4184
+ }
4185
+ try {
4186
+ window.parent.postMessage({ line: proto.iframeDebug, context: this.contextId, debug: encoded }, this.options.crossdomain?.parentDomain ?? '*');
4187
+ this.markSentToParent();
4188
+ }
4189
+ catch (e) {
4190
+ this.debug.error('iframe debug post failed', e);
4191
+ }
4192
+ };
4068
4193
  this.parentCrossDomainFrameListener = (event) => {
4069
4194
  const { data } = event;
4070
4195
  if (!data || event.source === window)
4071
4196
  return;
4197
+ // Debug: remember the last time the parent talked to us.
4198
+ if (data.line === proto.startIframe ||
4199
+ data.line === proto.parentAlive ||
4200
+ data.line === proto.iframeId ||
4201
+ data.line === proto.killIframe) {
4202
+ this.lastParentMsgAt = Date.now();
4203
+ }
4072
4204
  if (data.line === proto.startIframe) {
4073
4205
  // Avoid corrupting an in-flight start; let it complete.
4074
4206
  if (this.activityState === ActivityState.Starting)
@@ -4079,6 +4211,7 @@ class App {
4079
4211
  }
4080
4212
  if (data.token) {
4081
4213
  this.session.setSessionToken(data.token, this.projectKey);
4214
+ this.lastTokenReceived = { tok: String(data.token).slice(-8), at: Date.now() };
4082
4215
  }
4083
4216
  if (data.id !== undefined) {
4084
4217
  this.rootId = data.id;
@@ -4097,6 +4230,7 @@ class App {
4097
4230
  this.parentActive = true;
4098
4231
  this.rootId = data.id;
4099
4232
  this.session.setSessionToken(data.token, this.projectKey);
4233
+ this.lastTokenReceived = { tok: String(data.token).slice(-8), at: Date.now() };
4100
4234
  this.frameOderNumber = data.frameOrderNumber;
4101
4235
  this.frameLevel = data.frameLevel;
4102
4236
  this.debug.log('starting iframe tracking', data);
@@ -4109,14 +4243,110 @@ class App {
4109
4243
  }
4110
4244
  };
4111
4245
  this.trackedFrames = [];
4246
+ /** every context that has been enrolled at least once, to tell an orphan (re-adopt) apart
4247
+ * from a brand-new child still mid-enrollment (leave alone). */
4248
+ this.everTrackedFrames = new Set();
4112
4249
  this.frameLastSeen = new Map();
4250
+ /** crossdomain debug diagnostics, reported once per minute as an encoded console log */
4251
+ this.frameOrigin = new Map();
4252
+ this.frameAnyLastSeen = new Map();
4253
+ this.frameBatchLastSeen = new Map();
4254
+ this.frameLastSent = new Map();
4255
+ this.xdomainDebugInterval = null;
4256
+ /** last time we re-adopted a given orphaned context, to avoid restart spam */
4257
+ this.reAdoptCooldown = new Map();
4258
+ this.RE_ADOPT_COOLDOWN_MS = 2000;
4259
+ /**
4260
+ * Stable, collision-free frame-order allocation. Node ids are partitioned by
4261
+ * (frameLevel, frameOrder) via pack() — every (level, order) owns its own id block, so
4262
+ * two simultaneously-live frames sharing an order at the same level corrupt each other's
4263
+ * node trees and one stops rendering. The previous `trackedFrames.findIndex+1` derived
4264
+ * order from a mutable array index, and pruneStaleFrames()'s .filter() shifts those
4265
+ * indices, so a newly enrolled frame could be handed an order still in use by a live
4266
+ * (but pruned) frame. We instead assign each context a persistent order, unique among all
4267
+ * non-recycled contexts at its level, freed only when the context is GC'd (truly gone).
4268
+ */
4269
+ this.frameAlloc = new Map();
4270
+ this.usedOrdersByLevel = new Map();
4113
4271
  this.FRAME_STALE_MS = 1500;
4272
+ /**
4273
+ * Once per minute: emit an encoded console log from the parent tracker describing every
4274
+ * tracked child iframe and the freshness of our two-way communication with it. Lets us
4275
+ * see in replay which crossdomain iframe went silent and on which leg of the handshake.
4276
+ */
4277
+ /** drop debug entries for contexts we have neither heard from nor messaged in this long */
4278
+ this.XDOMAIN_DEBUG_RETENTION_MS = 10 * 60000;
4279
+ this.emitCrossdomainDebug = () => {
4280
+ if (this.insideIframe || !this.options.crossdomain?.enabled || !this.active())
4281
+ return;
4282
+ const now = Date.now();
4283
+ const rel = (t) => (t === undefined ? null : now - t);
4284
+ // Report the union of currently-tracked frames and every context we have any debug
4285
+ // record for: a frame that broke and stopped polling gets pruned from trackedFrames,
4286
+ // but it is exactly the one we want to surface (with a large lastAnyMsgAgoMs).
4287
+ const tracked = new Set(this.trackedFrames);
4288
+ const contexts = new Set([
4289
+ ...this.trackedFrames,
4290
+ ...this.frameAnyLastSeen.keys(),
4291
+ ...this.frameLastSent.keys(),
4292
+ ]);
4293
+ const frames = Array.from(contexts).map((ctx, i) => {
4294
+ const sent = this.frameLastSent.get(ctx);
4295
+ const alloc = this.frameAlloc.get(ctx);
4296
+ return {
4297
+ // the actual allocated (level, order) node-id partition, else an enumeration index
4298
+ n: alloc ? alloc.order : i + 1,
4299
+ level: alloc ? alloc.level : null,
4300
+ // identify by domain if we have it, otherwise the context id, otherwise the number
4301
+ id: this.frameOrigin.get(ctx) || ctx || `#${i + 1}`,
4302
+ tracked: tracked.has(ctx),
4303
+ lastAnyMsgAgoMs: rel(this.frameAnyLastSeen.get(ctx)),
4304
+ lastBatchAgoMs: rel(this.frameBatchLastSeen.get(ctx)),
4305
+ lastSent: sent ? { line: sent.line, agoMs: now - sent.t } : null,
4306
+ };
4307
+ });
4308
+ // GC: forget contexts that have been silent and un-messaged past the retention window.
4309
+ const cutoff = now - this.XDOMAIN_DEBUG_RETENTION_MS;
4310
+ for (const ctx of contexts) {
4311
+ if (tracked.has(ctx))
4312
+ continue;
4313
+ const seen = this.frameAnyLastSeen.get(ctx) ?? 0;
4314
+ const sentT = this.frameLastSent.get(ctx)?.t ?? 0;
4315
+ if (Math.max(seen, sentT) < cutoff) {
4316
+ this.frameOrigin.delete(ctx);
4317
+ this.frameAnyLastSeen.delete(ctx);
4318
+ this.frameBatchLastSeen.delete(ctx);
4319
+ this.frameLastSent.delete(ctx);
4320
+ this.reAdoptCooldown.delete(ctx);
4321
+ this.everTrackedFrames.delete(ctx);
4322
+ this.freeFrameOrder(ctx);
4323
+ }
4324
+ }
4325
+ const payload = { t: now, count: frames.length, frames };
4326
+ const json = JSON.stringify(payload);
4327
+ let encoded;
4328
+ try {
4329
+ // payload is ASCII (base36 contexts, URL origins, numbers), so plain base64 is safe
4330
+ encoded = btoa(json);
4331
+ }
4332
+ catch {
4333
+ encoded = json;
4334
+ }
4335
+ this.send(ConsoleLog('info', `[OR_XDOMAIN_DEBUG] ${encoded}`));
4336
+ };
4114
4337
  this.crossDomainIframeListener = (event) => {
4115
4338
  if (event.source === window)
4116
4339
  return;
4117
4340
  const { data } = event;
4118
4341
  if (!data)
4119
4342
  return;
4343
+ // Debug: remember when we last heard *anything* from this context, and its domain.
4344
+ if (data.context) {
4345
+ this.frameAnyLastSeen.set(data.context, Date.now());
4346
+ if (event.origin && !this.frameOrigin.has(data.context)) {
4347
+ this.frameOrigin.set(data.context, event.origin);
4348
+ }
4349
+ }
4120
4350
  // Record liveness regardless of our own active state so the queue can prune
4121
4351
  // stale contexts reliably once we resume dispatching commands after a cycle.
4122
4352
  if ((data.line === proto.polling || data.line === proto.iframeSignal) && data.context) {
@@ -4124,9 +4354,15 @@ class App {
4124
4354
  }
4125
4355
  if (!this.active())
4126
4356
  return;
4357
+ if (data.line === proto.iframeDebug) {
4358
+ // A child posted its once-per-minute snapshot; surface it in our recorded console.
4359
+ this.send(ConsoleLog('info', `[OR_XDOMAIN_IFRAME_DEBUG] ${data.debug}`));
4360
+ return;
4361
+ }
4127
4362
  if (data.line === proto.iframeSignal) {
4128
4363
  // @ts-ignore
4129
4364
  event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*');
4365
+ this.recordSentToFrame(data.context, proto.parentAlive);
4130
4366
  const signalId = async () => {
4131
4367
  if (event.source === null) {
4132
4368
  return console.error('Couldnt connect to event.source for child iframe tracking');
@@ -4143,22 +4379,25 @@ class App {
4143
4379
  else {
4144
4380
  this.trackedFrames.push(data.context);
4145
4381
  }
4382
+ this.everTrackedFrames.add(data.context);
4146
4383
  await this.waitStarted();
4147
4384
  const token = this.session.getSessionToken(this.projectKey);
4148
- const order = this.trackedFrames.findIndex((f) => f === data.context) + 1;
4149
- if (order === 0) {
4150
- this.debug.error('Couldnt get order number for iframe', data.context, this.trackedFrames);
4151
- }
4385
+ // Persistent, collision-free order (NOT the shifting array index). A restart of the
4386
+ // same context keeps its order/id-block for continuity; distinct live frames at the
4387
+ // same level never share one.
4388
+ const frameLevel = this.frameLevel + 1;
4389
+ const order = this.allocateFrameOrder(data.context, frameLevel);
4152
4390
  const iframeData = {
4153
4391
  line: proto.iframeId,
4154
4392
  id,
4155
4393
  token,
4156
4394
  frameOrderNumber: order,
4157
- frameLevel: this.frameLevel + 1,
4395
+ frameLevel,
4158
4396
  };
4159
4397
  this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData);
4160
4398
  // @ts-ignore
4161
4399
  event.source?.postMessage(iframeData, '*');
4400
+ this.recordSentToFrame(data.context, proto.iframeId);
4162
4401
  }
4163
4402
  catch (e) {
4164
4403
  console.error(e);
@@ -4171,6 +4410,9 @@ class App {
4171
4410
  * plus we rewrite some of the messages to be relative to the main context/window
4172
4411
  * */
4173
4412
  if (data.line === proto.iframeBatch) {
4413
+ if (data.context) {
4414
+ this.frameBatchLastSeen.set(data.context, Date.now());
4415
+ }
4174
4416
  const msgBatch = data.messages;
4175
4417
  const mappedMessages = [];
4176
4418
  msgBatch.forEach((msg) => {
@@ -4218,6 +4460,16 @@ class App {
4218
4460
  this.messages.push(...mappedMessages);
4219
4461
  }
4220
4462
  if (data.line === proto.polling) {
4463
+ // Self-heal: a live child that was enrolled before but fell out of trackedFrames
4464
+ // (pruned during a stop/start gap) keeps polling yet never re-signals. Re-adopt it
4465
+ // so it restarts and re-enrolls. We require everTrackedFrames so a brand-new child
4466
+ // still mid-enrollment (iframeSignal/checkNodeId in flight) is left alone.
4467
+ if (data.context &&
4468
+ this.everTrackedFrames.has(data.context) &&
4469
+ !this.trackedFrames.includes(data.context)) {
4470
+ this.reAdoptOrphanFrame(event, data.context);
4471
+ return;
4472
+ }
4221
4473
  if (!this.pollingQueue.order.length) {
4222
4474
  return;
4223
4475
  }
@@ -4254,6 +4506,7 @@ class App {
4254
4506
  }
4255
4507
  // @ts-ignore
4256
4508
  event.source?.postMessage(message, '*');
4509
+ this.recordSentToFrame(data.context, nextCommand);
4257
4510
  if (this.pollingQueue[nextCommand].length === 0) {
4258
4511
  delete this.pollingQueue[nextCommand];
4259
4512
  this.pollingQueue.order.shift();
@@ -4291,6 +4544,7 @@ class App {
4291
4544
  source: thisTab,
4292
4545
  context: this.contextId,
4293
4546
  }, this.options.crossdomain?.parentDomain ?? '*');
4547
+ this.markSentToParent();
4294
4548
  /**
4295
4549
  * since we need to wait uncertain amount of time
4296
4550
  * and I don't want to have recursion going on,
@@ -4311,6 +4565,7 @@ class App {
4311
4565
  source: thisTab,
4312
4566
  context: this.contextId,
4313
4567
  }, this.options.crossdomain?.parentDomain ?? '*');
4568
+ this.markSentToParent();
4314
4569
  this.debug.info('Trying to signal to parent, attempt:', retries + 1);
4315
4570
  retries++;
4316
4571
  };
@@ -4528,7 +4783,13 @@ class App {
4528
4783
  line: proto.polling,
4529
4784
  context: this.contextId,
4530
4785
  }, options.crossdomain?.parentDomain ?? '*');
4786
+ this.markSentToParent();
4531
4787
  }, 250);
4788
+ // Child-only: once per minute, post an encoded snapshot of our own tracking state
4789
+ // (active?, token received, last comms) up to the parent so it lands in the replay.
4790
+ if (this.iframeDebugInterval)
4791
+ clearInterval(this.iframeDebugInterval);
4792
+ this.iframeDebugInterval = setInterval(this.emitIframeDebug, 60000);
4532
4793
  }
4533
4794
  else {
4534
4795
  this.initWorker();
@@ -4537,6 +4798,13 @@ class App {
4537
4798
  * so they can act as if it was just a same-domain iframe
4538
4799
  * */
4539
4800
  window.addEventListener('message', this.crossDomainIframeListener);
4801
+ // Parent-only: once per minute, log an encoded snapshot of every tracked child
4802
+ // iframe and the freshness of our two-way comms, to debug iframes that go silent.
4803
+ if (this.options.crossdomain?.enabled) {
4804
+ if (this.xdomainDebugInterval)
4805
+ clearInterval(this.xdomainDebugInterval);
4806
+ this.xdomainDebugInterval = setInterval(this.emitCrossdomainDebug, 60000);
4807
+ }
4540
4808
  }
4541
4809
  if (this.bc !== null) {
4542
4810
  this.bc.postMessage({
@@ -4586,6 +4854,62 @@ class App {
4586
4854
  };
4587
4855
  }
4588
4856
  }
4857
+ /** stamp every outbound post to the parent window, for the child debug snapshot */
4858
+ markSentToParent() {
4859
+ this.lastSentToParentAt = Date.now();
4860
+ }
4861
+ allocateFrameOrder(ctx, level) {
4862
+ const existing = this.frameAlloc.get(ctx);
4863
+ if (existing !== undefined)
4864
+ return existing.order;
4865
+ let used = this.usedOrdersByLevel.get(level);
4866
+ if (!used) {
4867
+ used = new Set();
4868
+ this.usedOrdersByLevel.set(level, used);
4869
+ }
4870
+ let order = -1;
4871
+ for (let n = 1; n <= MASK_ORDER; n++) {
4872
+ if (!used.has(n)) {
4873
+ order = n;
4874
+ break;
4875
+ }
4876
+ }
4877
+ if (order === -1) {
4878
+ // Overflow (>127 live frames at one level): evict the least-recently-seen context at
4879
+ // this level that is not currently tracked, and reuse its slot rather than failing.
4880
+ let lru = null;
4881
+ let lruSeen = Infinity;
4882
+ const trackedSet = new Set(this.trackedFrames);
4883
+ this.frameAlloc.forEach((alloc, c) => {
4884
+ if (alloc.level !== level || trackedSet.has(c))
4885
+ return;
4886
+ const seen = this.frameAnyLastSeen.get(c) ?? 0;
4887
+ if (seen < lruSeen) {
4888
+ lruSeen = seen;
4889
+ lru = c;
4890
+ }
4891
+ });
4892
+ if (lru !== null) {
4893
+ order = this.frameAlloc.get(lru).order;
4894
+ this.frameAlloc.delete(lru);
4895
+ this.debug.error('OR: frame order space exhausted, evicting', lru, 'for', ctx);
4896
+ }
4897
+ else {
4898
+ order = MASK_ORDER;
4899
+ this.debug.error('OR: frame order overflow, reusing max order for', ctx);
4900
+ }
4901
+ }
4902
+ used.add(order);
4903
+ this.frameAlloc.set(ctx, { order, level });
4904
+ return order;
4905
+ }
4906
+ freeFrameOrder(ctx) {
4907
+ const alloc = this.frameAlloc.get(ctx);
4908
+ if (!alloc)
4909
+ return;
4910
+ this.frameAlloc.delete(ctx);
4911
+ this.usedOrdersByLevel.get(alloc.level)?.delete(alloc.order);
4912
+ }
4589
4913
  pruneStaleFrames() {
4590
4914
  const staleAfter = Date.now() - this.FRAME_STALE_MS;
4591
4915
  this.trackedFrames = this.trackedFrames.filter((ctx) => {
@@ -4596,6 +4920,45 @@ class App {
4596
4920
  return false;
4597
4921
  });
4598
4922
  }
4923
+ /** records the last command/signal we posted to a given child iframe context (debug) */
4924
+ recordSentToFrame(ctx, line) {
4925
+ if (!ctx)
4926
+ return;
4927
+ this.frameLastSent.set(ctx, { line: protoLabel[line] ?? line, t: Date.now() });
4928
+ }
4929
+ /**
4930
+ * Self-heal for the "kill-then-prune orphan" race: a live child can fall out of
4931
+ * `trackedFrames` (its 250ms poll was delayed past FRAME_STALE_MS during the parent's
4932
+ * stop/start NotActive gap, so pruneStaleFrames evicted it). It keeps polling but the
4933
+ * only re-enrollment path is an `iframeSignal`, which a stopped/active-but-orphaned
4934
+ * child never re-emits — so it would record nothing forever. When we (the parent) are
4935
+ * active and see a poll from an un-tracked context, push a `startIframe` so the child
4936
+ * restarts, re-runs the full handshake and re-observes with a fresh rootId. Cooldowned
4937
+ * so we don't spam restarts during the child's start window.
4938
+ */
4939
+ reAdoptOrphanFrame(event, ctx) {
4940
+ const now = Date.now();
4941
+ const last = this.reAdoptCooldown.get(ctx) ?? 0;
4942
+ if (now - last < this.RE_ADOPT_COOLDOWN_MS)
4943
+ return;
4944
+ this.reAdoptCooldown.set(ctx, now);
4945
+ const message = {
4946
+ line: proto.startIframe,
4947
+ token: this.session.getSessionToken(this.projectKey),
4948
+ };
4949
+ const targetFrame = this.pageFrames.find((f) => f.contentWindow === event.source) ||
4950
+ Array.from(document.querySelectorAll('iframe')).find((f) => f.contentWindow === event.source);
4951
+ if (targetFrame) {
4952
+ const nodeId = targetFrame[this.options.node_id];
4953
+ if (nodeId !== undefined) {
4954
+ message.id = nodeId;
4955
+ }
4956
+ }
4957
+ // @ts-ignore
4958
+ event.source?.postMessage(message, '*');
4959
+ this.recordSentToFrame(ctx, proto.startIframe);
4960
+ this.debug.log('Re-adopting orphaned crossdomain iframe', ctx);
4961
+ }
4599
4962
  allowAppStart() {
4600
4963
  this.canStart = true;
4601
4964
  if (this.startTimeout) {
@@ -4687,6 +5050,16 @@ class App {
4687
5050
  }
4688
5051
  else if (data === 'not_init') {
4689
5052
  this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker');
5053
+ // [OPENREPLAYDEBUG] THEORY: a message batch reached the worker before the
5054
+ // writer was initialised (start handshake not finished) and was DISCARDED.
5055
+ // Surface it into the session (searchable in replay) to test whether
5056
+ // startup batches — which can carry the initial DOM snapshot — are lost.
5057
+ try {
5058
+ this.send(ConsoleLog('warn', '[OPENREPLAYDEBUG] worker not_init: a pre-start message batch was discarded'));
5059
+ }
5060
+ catch (_) {
5061
+ /* diagnostics must never break recording */
5062
+ }
4690
5063
  }
4691
5064
  else if (data.type === 'failure') {
4692
5065
  this.stop(false);
@@ -4761,7 +5134,9 @@ class App {
4761
5134
  window.parent.postMessage({
4762
5135
  line: proto.iframeBatch,
4763
5136
  messages: this.messages,
5137
+ context: this.contextId,
4764
5138
  }, this.options.crossdomain?.parentDomain ?? '*');
5139
+ this.markSentToParent();
4765
5140
  this.commitCallbacks.forEach((cb) => cb(this.messages));
4766
5141
  this.messages.length = 0;
4767
5142
  return;
@@ -7017,15 +7392,28 @@ function CSSRules (app, opts) {
7017
7392
  if (!nodeID)
7018
7393
  return;
7019
7394
  const sheet = node.sheet;
7395
+ // Accessing cssRules on a cross-origin stylesheet (e.g. injected by a
7396
+ // browser extension) throws a SecurityError. Probe it before registering
7397
+ // the sheet so an inaccessible sheet is skipped instead of aborting start.
7398
+ let rules;
7399
+ try {
7400
+ rules = sheet.cssRules;
7401
+ }
7402
+ catch (e) {
7403
+ // Skip inaccessible (cross-origin) stylesheet
7404
+ app.debug.log('Couldnt access stylesheet during initial scan', e);
7405
+ return;
7406
+ }
7020
7407
  const sheetID = nextID();
7021
7408
  styleSheetIDMap.set(sheet, sheetID);
7022
7409
  app.send(AdoptedSSAddOwner(sheetID, nodeID));
7023
- for (let i = 0; i < sheet.cssRules.length; i++) {
7410
+ for (let i = 0; i < rules.length; i++) {
7024
7411
  try {
7025
- sendInsertDeleteRule(sheet, i, sheet.cssRules[i].cssText);
7412
+ sendInsertDeleteRule(sheet, i, rules[i].cssText);
7026
7413
  }
7027
7414
  catch (e) {
7028
7415
  // Skip inaccessible rules
7416
+ app.debug.log('Couldnt access stylesheet rule during initial scan', e);
7029
7417
  }
7030
7418
  }
7031
7419
  });
@@ -7454,7 +7842,7 @@ class NetworkMessage {
7454
7842
  return null;
7455
7843
  const gqlHeader = "application/graphql-response";
7456
7844
  const isGraphql = messageInfo.url.includes("/graphql")
7457
- || Object.values(messageInfo.request.headers).some(v => v && typeof v === 'string' && v.includes(gqlHeader));
7845
+ || Object.values(messageInfo.request.headers).some(v => v.includes(gqlHeader));
7458
7846
  if (isGraphql && messageInfo.response.body && typeof messageInfo.response.body === 'string') {
7459
7847
  const isError = messageInfo.response.body.includes("errors");
7460
7848
  messageInfo.status = isError ? 400 : 200;
@@ -7558,7 +7946,6 @@ const genStringBody = (body) => {
7558
7946
  }
7559
7947
  else if (body instanceof Blob ||
7560
7948
  body instanceof ReadableStream ||
7561
- ArrayBuffer.isView(body) ||
7562
7949
  body instanceof ArrayBuffer) {
7563
7950
  result = 'byte data';
7564
7951
  }
@@ -9047,7 +9434,7 @@ class ConstantProperties {
9047
9434
  user_id: this.user_id,
9048
9435
  distinct_id: this.deviceId,
9049
9436
  sdk_edition: 'web',
9050
- sdk_version: '18.0.11',
9437
+ sdk_version: '18.0.13-beta.0',
9051
9438
  timezone: getUTCOffsetString(),
9052
9439
  search_engine: this.searchEngine,
9053
9440
  };
@@ -9749,7 +10136,7 @@ class API {
9749
10136
  this.signalStartIssue = (reason, missingApi) => {
9750
10137
  const doNotTrack = this.checkDoNotTrack();
9751
10138
  console.log("Tracker couldn't start due to:", JSON.stringify({
9752
- trackerVersion: '18.0.11',
10139
+ trackerVersion: '18.0.13-beta.0',
9753
10140
  projectKey: this.options.projectKey,
9754
10141
  doNotTrack,
9755
10142
  reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,