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