@agent-native/core 0.98.2 → 0.98.4

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.
Files changed (78) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +23 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +2 -2
  5. package/corpus/core/src/client/analytics.ts +12 -0
  6. package/corpus/core/src/client/index.ts +2 -0
  7. package/corpus/core/src/client/session-replay.ts +612 -26
  8. package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
  9. package/corpus/core/src/client/settings/SettingsPanel.tsx +31 -211
  10. package/corpus/core/src/client/settings/agent-settings-search.ts +219 -0
  11. package/corpus/core/src/client/settings/index.ts +4 -0
  12. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  13. package/corpus/core/src/demo/config.ts +6 -6
  14. package/corpus/core/src/demo/fetch-interceptor.ts +25 -2
  15. package/corpus/core/src/demo/redact.ts +57 -66
  16. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +65 -6
  17. package/corpus/templates/analytics/AGENTS.md +6 -6
  18. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +429 -244
  19. package/corpus/templates/analytics/app/components/layout/command-palette-search.ts +93 -0
  20. package/corpus/templates/analytics/app/global.css +4 -62
  21. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +2 -1
  22. package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +46 -33
  23. package/corpus/templates/analytics/app/pages/Settings.tsx +8 -45
  24. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +413 -105
  25. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +32 -16
  26. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +126 -0
  27. package/corpus/templates/analytics/changelog/2026-07-12-command-menu-loading-placeholders-now-stay-below-available-r.md +6 -0
  28. package/corpus/templates/analytics/changelog/2026-07-12-command-menu-search-now-prioritizes-the-best-match.md +6 -0
  29. package/corpus/templates/analytics/changelog/2026-07-12-demo-charts-preserve-source-movement.md +6 -0
  30. package/corpus/templates/analytics/changelog/2026-07-12-fixed-session-replays-that-appeared-ultra-wide-hid-recorded-.md +6 -0
  31. package/corpus/templates/analytics/changelog/2026-07-12-replay-storage-settings-stay-readable-at-narrow-widths.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-load-faster-in-demo-mode-while-visitor-email.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-preserve-recorded-styling-and-keep-malformed.md +6 -0
  34. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +11 -2
  35. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +14 -2
  36. package/corpus/templates/calendar/changelog/2026-07-12-calendar-responses-can-now-be-saved-with-command-enter-or-ct.md +6 -0
  37. package/dist/agent/production-agent.js +2 -2
  38. package/dist/agent/production-agent.js.map +1 -1
  39. package/dist/client/analytics.d.ts.map +1 -1
  40. package/dist/client/analytics.js +11 -0
  41. package/dist/client/analytics.js.map +1 -1
  42. package/dist/client/index.d.ts +1 -1
  43. package/dist/client/index.d.ts.map +1 -1
  44. package/dist/client/index.js +1 -1
  45. package/dist/client/index.js.map +1 -1
  46. package/dist/client/session-replay.d.ts +12 -3
  47. package/dist/client/session-replay.d.ts.map +1 -1
  48. package/dist/client/session-replay.js +488 -23
  49. package/dist/client/session-replay.js.map +1 -1
  50. package/dist/client/settings/DemoModeSection.d.ts +3 -3
  51. package/dist/client/settings/DemoModeSection.js +4 -4
  52. package/dist/client/settings/DemoModeSection.js.map +1 -1
  53. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  54. package/dist/client/settings/SettingsPanel.js +41 -191
  55. package/dist/client/settings/SettingsPanel.js.map +1 -1
  56. package/dist/client/settings/agent-settings-search.d.ts +16 -0
  57. package/dist/client/settings/agent-settings-search.d.ts.map +1 -0
  58. package/dist/client/settings/agent-settings-search.js +178 -0
  59. package/dist/client/settings/agent-settings-search.js.map +1 -0
  60. package/dist/client/settings/index.d.ts +1 -0
  61. package/dist/client/settings/index.d.ts.map +1 -1
  62. package/dist/client/settings/index.js +1 -0
  63. package/dist/client/settings/index.js.map +1 -1
  64. package/dist/collab/routes.d.ts +1 -1
  65. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  66. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  67. package/dist/demo/config.js +6 -6
  68. package/dist/demo/config.js.map +1 -1
  69. package/dist/demo/fetch-interceptor.d.ts +1 -0
  70. package/dist/demo/fetch-interceptor.d.ts.map +1 -1
  71. package/dist/demo/fetch-interceptor.js +19 -2
  72. package/dist/demo/fetch-interceptor.js.map +1 -1
  73. package/dist/demo/redact.d.ts +14 -5
  74. package/dist/demo/redact.d.ts.map +1 -1
  75. package/dist/demo/redact.js +30 -56
  76. package/dist/demo/redact.js.map +1 -1
  77. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  78. package/package.json +2 -2
@@ -42,6 +42,11 @@ const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
42
42
  const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
43
43
  const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
44
44
  const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
45
+ /** Cross-tab channel name used by the duplicated-tab claim guard. */
46
+ const SESSION_REPLAY_BROADCAST_CHANNEL_NAME = "agent-native-session-replay";
47
+ /** How long a resuming tab waits for a "someone else already owns this
48
+ * replayId" reply before proceeding to record with the resumed id. */
49
+ const SESSION_REPLAY_CLAIM_TIMEOUT_MS = 150;
45
50
  /** rrweb custom-event tag for captured console/window-error entries. */
46
51
  export const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
47
52
  /** rrweb custom-event tag for captured fetch/XHR request summaries. */
@@ -111,26 +116,63 @@ function getState() {
111
116
  restoreCaptures: null,
112
117
  options: null,
113
118
  lastAuthenticatedProperties: null,
119
+ resourceNodes: new Map(),
120
+ automaticConflictRestartAttempted: false,
121
+ broadcastChannel: null,
114
122
  };
115
123
  }
116
- return g[SESSION_REPLAY_STATE_KEY];
117
- }
118
- function safeStorageGet(key) {
124
+ const state = g[SESSION_REPLAY_STATE_KEY];
125
+ // Keep Vite HMR safe when an older recorder state survives a module reload.
126
+ state.resourceNodes ??= new Map();
127
+ return state;
128
+ }
129
+ // The replay session record (replayId + sequence counter) lives in
130
+ // `sessionStorage`, not `localStorage`: `localStorage` is shared by every
131
+ // open tab of the origin, which would hand every tab the same `replayId` and
132
+ // the same sequence counter. See the guard comment above
133
+ // `getOrCreateReplaySession` for the corruption that causes.
134
+ function safeSessionStorageGet(key) {
119
135
  try {
120
- return window.localStorage.getItem(key);
136
+ return window.sessionStorage.getItem(key);
121
137
  }
122
138
  catch {
123
139
  return null;
124
140
  }
125
141
  }
126
- function safeStorageSet(key, value) {
142
+ function safeSessionStorageSet(key, value) {
143
+ try {
144
+ window.sessionStorage.setItem(key, value);
145
+ }
146
+ catch {
147
+ // private browsing / storage disabled -- replay still works for this page
148
+ }
149
+ }
150
+ function safeSessionStorageRemove(key) {
127
151
  try {
128
- window.localStorage.setItem(key, value);
152
+ window.sessionStorage.removeItem(key);
129
153
  }
130
154
  catch {
131
155
  // private browsing / storage disabled -- replay still works for this page
132
156
  }
133
157
  }
158
+ let legacyLocalStorageReplaySessionCleared = false;
159
+ /**
160
+ * Best-effort, one-time removal of the pre-fix replay session record that
161
+ * used to live in `localStorage`. Never read from it -- adopting its
162
+ * `replayId` would recreate the exact shared-identity bug this file now
163
+ * avoids by using `sessionStorage` instead.
164
+ */
165
+ function clearLegacyLocalStorageReplaySession() {
166
+ if (legacyLocalStorageReplaySessionCleared)
167
+ return;
168
+ legacyLocalStorageReplaySessionCleared = true;
169
+ try {
170
+ window.localStorage.removeItem(SESSION_REPLAY_ID_STORAGE_KEY);
171
+ }
172
+ catch {
173
+ // best-effort only -- a stray legacy record is harmless once ignored
174
+ }
175
+ }
134
176
  function generateReplayId() {
135
177
  try {
136
178
  if (typeof crypto !== "undefined" &&
@@ -144,7 +186,7 @@ function generateReplayId() {
144
186
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
145
187
  }
146
188
  function readStoredReplaySession() {
147
- const raw = safeStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
189
+ const raw = safeSessionStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
148
190
  if (!raw)
149
191
  return null;
150
192
  try {
@@ -158,9 +200,37 @@ function readStoredReplaySession() {
158
200
  }
159
201
  }
160
202
  function writeStoredReplaySession(value) {
161
- safeStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
203
+ safeSessionStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
162
204
  }
205
+ function removeStoredReplaySession(replayId) {
206
+ if (readStoredReplaySession()?.replayId !== replayId)
207
+ return;
208
+ safeSessionStorageRemove(SESSION_REPLAY_ID_STORAGE_KEY);
209
+ }
210
+ /**
211
+ * Per-tab replay identity is deliberate -- do not "fix" this by reading or
212
+ * writing the session record through `localStorage` again.
213
+ *
214
+ * `sessionStorage` is scoped to a single tab (and survives reloads/
215
+ * navigations within that tab, which is exactly the lifetime a recording
216
+ * needs). `localStorage` is shared by every open tab of the origin. If this
217
+ * record lived there, two tabs open to the same app would read/write the
218
+ * *same* `replayId` and the *same* sequence counter, so rrweb in each tab
219
+ * would record independently but upload chunks under one shared identity.
220
+ * The two interleaved DOM mutation streams get merged into a single
221
+ * recording server-side: mutations reference the other tab's node ids
222
+ * (broken CSS), the viewport/meta events reflect whichever tab resized last
223
+ * (wrong or ultra-wide viewport), and lost mousemove batches from the
224
+ * "other" tab's chunks read as a frozen cursor or a fake inactivity gap. A
225
+ * chunk-sequence collision with a different checksum gets rejected
226
+ * server-side (409) rather than merged, so one tab's batches are silently
227
+ * dropped -- there is no way to reconstruct or repair this at playback time.
228
+ * Keep this per-tab. A tab *duplicated* mid-session still shares a
229
+ * `sessionStorage` snapshot, which is what the `BroadcastChannel` claim
230
+ * check in `startSessionReplayRecorder` guards against.
231
+ */
163
232
  function getOrCreateReplaySession(sessionId) {
233
+ clearLegacyLocalStorageReplaySession();
164
234
  const parsed = readStoredReplaySession();
165
235
  if (parsed?.sessionId === sessionId && parsed.replayId) {
166
236
  const startedAtMs = typeof parsed.startedAtMs === "number" &&
@@ -173,12 +243,115 @@ function getOrCreateReplaySession(sessionId) {
173
243
  parsed.sequence >= 0
174
244
  ? Math.floor(parsed.sequence)
175
245
  : 0;
176
- return { replayId: parsed.replayId, startedAtMs, sequence };
246
+ return { replayId: parsed.replayId, startedAtMs, sequence, resumed: true };
177
247
  }
178
248
  const replayId = generateReplayId();
179
249
  const startedAtMs = Date.now();
180
250
  writeStoredReplaySession({ sessionId, replayId, startedAtMs, sequence: 0 });
181
- return { replayId, startedAtMs, sequence: 0 };
251
+ return { replayId, startedAtMs, sequence: 0, resumed: false };
252
+ }
253
+ /**
254
+ * Open the cross-tab claim channel used to detect a *duplicated* tab (a
255
+ * browser "duplicate tab" or same-origin `window.open` copies
256
+ * `sessionStorage`, so two tabs can legitimately start with the same
257
+ * resumed `replayId`). Returns `null` when `BroadcastChannel` is
258
+ * unavailable -- callers must treat that as "skip the guard", never as an
259
+ * error.
260
+ */
261
+ function openReplayBroadcastChannel() {
262
+ if (typeof BroadcastChannel === "undefined")
263
+ return null;
264
+ try {
265
+ return new BroadcastChannel(SESSION_REPLAY_BROADCAST_CHANNEL_NAME);
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ /**
272
+ * Wires up the duplicated-tab claim channel for one recorder lifetime.
273
+ * `respond` keeps listening for the whole life of the channel (any tab may
274
+ * later claim the `replayId` this tab is actively recording); `probeClaim`
275
+ * is used once, only when resuming a stored session, to ask "is anyone else
276
+ * already recording this id?" and wait up to
277
+ * `SESSION_REPLAY_CLAIM_TIMEOUT_MS` for a reply.
278
+ */
279
+ function createReplayClaimChannel(state, instanceNonce) {
280
+ const channel = openReplayBroadcastChannel();
281
+ if (!channel) {
282
+ return { channel: null, probeClaim: async () => false };
283
+ }
284
+ let pending = null;
285
+ channel.onmessage = (event) => {
286
+ const data = event?.data;
287
+ if (!data || typeof data !== "object")
288
+ return;
289
+ if (data.type === "an-replay-claim") {
290
+ if (data.instanceNonce === instanceNonce)
291
+ return;
292
+ const ownsReplayId = state.active && state.replayId === data.replayId;
293
+ const winsSimultaneousClaim = pending?.replayId === data.replayId &&
294
+ instanceNonce.localeCompare(data.instanceNonce) < 0;
295
+ if (!ownsReplayId && !winsSimultaneousClaim) {
296
+ // If both duplicated tabs start simultaneously, deterministically
297
+ // yield to the lower nonce rather than letting both probes time out
298
+ // and record under the copied replay id.
299
+ if (pending?.replayId === data.replayId &&
300
+ data.instanceNonce.localeCompare(instanceNonce) < 0) {
301
+ window.clearTimeout(pending.timer);
302
+ const resolve = pending.resolve;
303
+ pending = null;
304
+ resolve(true);
305
+ }
306
+ return;
307
+ }
308
+ try {
309
+ const reply = {
310
+ type: "an-replay-claim-taken",
311
+ replayId: data.replayId,
312
+ claimantNonce: data.instanceNonce,
313
+ };
314
+ channel.postMessage(reply);
315
+ }
316
+ catch {
317
+ // best-effort -- a lost reply just means the duplicate tab resumes
318
+ // recording under the shared id; later 409s still protect the
319
+ // stream from getting corrupted merges.
320
+ }
321
+ return;
322
+ }
323
+ if (data.type === "an-replay-claim-taken") {
324
+ if (pending &&
325
+ data.replayId === pending.replayId &&
326
+ (!data.claimantNonce || data.claimantNonce === instanceNonce)) {
327
+ window.clearTimeout(pending.timer);
328
+ const resolve = pending.resolve;
329
+ pending = null;
330
+ resolve(true);
331
+ }
332
+ }
333
+ };
334
+ const probeClaim = (replayId) => new Promise((resolve) => {
335
+ const timer = window.setTimeout(() => {
336
+ pending = null;
337
+ resolve(false);
338
+ }, SESSION_REPLAY_CLAIM_TIMEOUT_MS);
339
+ pending = { replayId, resolve, timer };
340
+ try {
341
+ const claim = {
342
+ type: "an-replay-claim",
343
+ replayId,
344
+ instanceNonce,
345
+ };
346
+ channel.postMessage(claim);
347
+ }
348
+ catch {
349
+ window.clearTimeout(timer);
350
+ pending = null;
351
+ resolve(false);
352
+ }
353
+ });
354
+ return { channel, probeClaim };
182
355
  }
183
356
  function persistReplaySequence(sessionId, replayId, startedAtMs, sequence) {
184
357
  writeStoredReplaySession({
@@ -361,6 +534,7 @@ function normalizeOptions(options) {
361
534
  eventSampling: options.eventSampling ?? DEFAULT_EVENT_SAMPLING,
362
535
  console: normalizeCaptureToggle(options.console, DEFAULT_MAX_CONSOLE_EVENTS),
363
536
  network: normalizeCaptureToggle(options.network, DEFAULT_MAX_NETWORK_EVENTS),
537
+ onUploadRejected: options.onUploadRejected,
364
538
  extraProperties: options.extraProperties,
365
539
  shouldStart: options.shouldStart,
366
540
  };
@@ -424,17 +598,141 @@ function scrubReplayValue(value, key = "", depth = 0, seen = new WeakSet()) {
424
598
  * the scrub into the single serialization pass avoids a separate deep-clone of
425
599
  * every emitted event (FullSnapshots are large DOM trees) on the hot path.
426
600
  */
427
- function scrubReplayReplacer(key, value) {
428
- return typeof value === "string" ? scrubStringValue(key, value) : value;
601
+ const REPLAY_RESOURCE_LINK_RELS = new Set([
602
+ "stylesheet",
603
+ "icon",
604
+ "apple-touch-icon",
605
+ "mask-icon",
606
+ ]);
607
+ const REPLAY_RESOURCE_PRELOAD_TYPES = new Set([
608
+ "style",
609
+ "font",
610
+ "image",
611
+ "audio",
612
+ "video",
613
+ "track",
614
+ ]);
615
+ const REPLAY_RESOURCE_TAGS = new Set([
616
+ "img",
617
+ "source",
618
+ "video",
619
+ "audio",
620
+ "track",
621
+ "input",
622
+ "link",
623
+ ]);
624
+ const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set();
625
+ const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
626
+ const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
627
+ const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
628
+ const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
629
+ function replayAttributeString(attributes, key) {
630
+ return typeof attributes[key] === "string"
631
+ ? attributes[key].toLowerCase()
632
+ : "";
633
+ }
634
+ function updateReplayResourceNode(current, attributes) {
635
+ return {
636
+ tagName: current.tagName,
637
+ rel: Object.hasOwn(attributes, "rel")
638
+ ? replayAttributeString(attributes, "rel")
639
+ : current.rel,
640
+ as: Object.hasOwn(attributes, "as")
641
+ ? replayAttributeString(attributes, "as")
642
+ : current.as,
643
+ type: Object.hasOwn(attributes, "type")
644
+ ? replayAttributeString(attributes, "type")
645
+ : current.type,
646
+ };
647
+ }
648
+ function replayPreservedResourceAttributes(node) {
649
+ switch (node.tagName) {
650
+ case "img":
651
+ case "source":
652
+ return REPLAY_SRCSET_ATTRIBUTES;
653
+ case "video":
654
+ return REPLAY_VIDEO_ATTRIBUTES;
655
+ case "audio":
656
+ case "track":
657
+ return REPLAY_SRC_ATTRIBUTES;
658
+ case "input":
659
+ return node.type === "image"
660
+ ? REPLAY_SRC_ATTRIBUTES
661
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
662
+ case "link": {
663
+ const rels = node.rel.split(/\s+/);
664
+ const isLoadBearingResource = rels.some((rel) => REPLAY_RESOURCE_LINK_RELS.has(rel)) ||
665
+ (rels.includes("preload") &&
666
+ REPLAY_RESOURCE_PRELOAD_TYPES.has(node.as));
667
+ return isLoadBearingResource
668
+ ? REPLAY_HREF_ATTRIBUTES
669
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
670
+ }
671
+ default:
672
+ return NO_REPLAY_RESOURCE_ATTRIBUTES;
673
+ }
674
+ }
675
+ /**
676
+ * Build a path-aware replay serializer without cloning the rrweb event.
677
+ *
678
+ * Privacy still wins for Meta/navigation URLs, executable/embed URLs, anchor
679
+ * hrefs, and custom console/network diagnostics. The narrow exception is
680
+ * load-bearing stylesheet, font, image, and media attributes: changing those
681
+ * signed URLs makes rrweb rebuild a page that never existed. JSON.stringify
682
+ * calls a replacer for an `attributes` object before its children, so the
683
+ * WeakMap lets the child callback recognize only that bag.
684
+ */
685
+ function createReplayScrubReplacer(resourceNodes) {
686
+ const preservedAttributes = new WeakMap();
687
+ return function replayScrubReplacer(key, value) {
688
+ if (key === "attributes" && value && typeof value === "object") {
689
+ const attributes = value;
690
+ const holder = this && typeof this === "object"
691
+ ? this
692
+ : undefined;
693
+ const tagName = typeof holder?.tagName === "string" ? holder.tagName.toLowerCase() : "";
694
+ const nodeId = typeof holder?.id === "number" && Number.isFinite(holder.id)
695
+ ? holder.id
696
+ : undefined;
697
+ let resourceNode;
698
+ if (tagName && REPLAY_RESOURCE_TAGS.has(tagName)) {
699
+ resourceNode = updateReplayResourceNode({ tagName, rel: "", as: "", type: "" }, attributes);
700
+ }
701
+ else if (nodeId !== undefined && !tagName) {
702
+ const current = resourceNodes.get(nodeId);
703
+ if (current) {
704
+ resourceNode = updateReplayResourceNode(current, attributes);
705
+ }
706
+ }
707
+ if (nodeId !== undefined && resourceNode) {
708
+ resourceNodes.set(nodeId, resourceNode);
709
+ }
710
+ const resourceKeys = resourceNode
711
+ ? replayPreservedResourceAttributes(resourceNode)
712
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
713
+ if (resourceKeys.size > 0)
714
+ preservedAttributes.set(value, resourceKeys);
715
+ return value;
716
+ }
717
+ if (typeof value === "string" &&
718
+ this &&
719
+ typeof this === "object" &&
720
+ preservedAttributes.get(this)?.has(key.toLowerCase())) {
721
+ return value;
722
+ }
723
+ return typeof value === "string" ? scrubStringValue(key, value) : value;
724
+ };
429
725
  }
430
726
  /**
431
727
  * Serialize + scrub one event in a single pass. The resulting string is stored
432
728
  * directly on the queue and reused verbatim at flush, so each event is
433
729
  * stringified exactly once (was: deep-clone + size-stringify + flush-stringify).
434
730
  */
435
- function serializeReplayEvent(event) {
731
+ function serializeReplayEvent(event, resourceNodes) {
436
732
  try {
437
- return JSON.stringify(event, scrubReplayReplacer);
733
+ if (event.type === 2)
734
+ resourceNodes.clear();
735
+ return JSON.stringify(event, createReplayScrubReplacer(resourceNodes));
438
736
  }
439
737
  catch {
440
738
  return "";
@@ -455,7 +753,7 @@ function replayEventTimestampMs(event) {
455
753
  function enqueueReplayEvent(state, event) {
456
754
  if (!state.options)
457
755
  return;
458
- const serialized = serializeReplayEvent(event);
756
+ const serialized = serializeReplayEvent(event, state.resourceNodes);
459
757
  if (!serialized)
460
758
  return;
461
759
  const estimatedBytes = serialized.length;
@@ -629,6 +927,25 @@ function replayUploadBodyBytes(body) {
629
927
  function canUseReplayKeepalive(body) {
630
928
  return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
631
929
  }
930
+ /** Thrown by `sendReplayUpload` on a non-ok HTTP response, carrying the
931
+ * status so `flushSessionReplay` can tell a permanent client rejection
932
+ * (e.g. a 409 checksum conflict, which can never succeed on retry) apart
933
+ * from a transient failure worth retrying. */
934
+ class ReplayUploadHttpError extends Error {
935
+ status;
936
+ constructor(status) {
937
+ super(`Session replay upload failed with HTTP ${status}`);
938
+ this.name = "ReplayUploadHttpError";
939
+ this.status = status;
940
+ }
941
+ }
942
+ /** 4xx statuses where retrying the exact same batch can never succeed --
943
+ * e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
944
+ * reject again. 408 (timeout) and 429 (rate limit) are excluded because a
945
+ * later retry can plausibly succeed. */
946
+ function isDefinitiveReplayUploadClientError(status) {
947
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
948
+ }
632
949
  async function sendReplayUpload(options, body, callbacks = {}) {
633
950
  if (isCrossOriginReplayEndpoint(options.endpoint)) {
634
951
  const canUseKeepalive = canUseReplayKeepalive(body);
@@ -641,7 +958,7 @@ async function sendReplayUpload(options, body, callbacks = {}) {
641
958
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
642
959
  });
643
960
  if (!response.ok) {
644
- throw new Error(`Session replay upload failed with HTTP ${response.status}`);
961
+ throw new ReplayUploadHttpError(response.status);
645
962
  }
646
963
  return;
647
964
  }
@@ -659,7 +976,7 @@ async function sendReplayUpload(options, body, callbacks = {}) {
659
976
  },
660
977
  });
661
978
  if (!response.ok) {
662
- throw new Error(`Session replay upload failed with HTTP ${response.status}`);
979
+ throw new ReplayUploadHttpError(response.status);
663
980
  }
664
981
  }
665
982
  function isFinalFlushReason(reason) {
@@ -739,6 +1056,7 @@ export async function flushSessionReplay(reason = "manual") {
739
1056
  state.flushing = true;
740
1057
  let uploaded = false;
741
1058
  let reservedSequence = false;
1059
+ let definitiveClientErrorStatus = null;
742
1060
  try {
743
1061
  await sendReplayUpload(state.options, payload.body, {
744
1062
  beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
@@ -750,18 +1068,44 @@ export async function flushSessionReplay(reason = "manual") {
750
1068
  });
751
1069
  if (!reservedSequence)
752
1070
  advanceReplaySequence(state, payload);
1071
+ state.automaticConflictRestartAttempted = false;
753
1072
  uploaded = true;
754
1073
  }
755
1074
  catch (error) {
756
1075
  if (reservedSequence)
757
1076
  rollbackReplaySequenceReservation(state, payload);
758
- restoreReplayEvents(state, events);
1077
+ // A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
1078
+ // never succeed by retrying the exact same batch -- requeuing it would
1079
+ // just spin forever, blocking every later batch behind it (flushes are
1080
+ // FIFO via `retryBatches`). Drop it and move on instead.
1081
+ const isDefinitiveClientError = error instanceof ReplayUploadHttpError &&
1082
+ isDefinitiveReplayUploadClientError(error.status);
1083
+ if (isDefinitiveClientError) {
1084
+ // Continuing after a checksum/sequence conflict would reuse the same
1085
+ // rejected sequence forever. More importantly, advancing past it would
1086
+ // append mutations to a replay whose DOM stream may belong to another
1087
+ // tab. End this recorder and clear its persisted identity so the next
1088
+ // start creates a clean replay instead of producing corrupt playback.
1089
+ state.queue = [];
1090
+ state.queuedBytes = 0;
1091
+ state.retryBatches = [];
1092
+ removeStoredReplaySession(payload.replayId);
1093
+ definitiveClientErrorStatus = error.status;
1094
+ }
1095
+ else {
1096
+ restoreReplayEvents(state, events);
1097
+ }
759
1098
  // Guard the recorder's own warning so console capture never records it
760
1099
  // (a captured warning would enqueue an event and retrigger a flush).
761
1100
  const previousInternal = replayCaptureInternal;
762
1101
  replayCaptureInternal = true;
763
1102
  try {
764
- console.warn("[session-replay] upload failed", error);
1103
+ if (isDefinitiveClientError) {
1104
+ console.warn(`[session-replay] dropping upload (HTTP ${error.status})`, error);
1105
+ }
1106
+ else {
1107
+ console.warn("[session-replay] upload failed", error);
1108
+ }
765
1109
  }
766
1110
  finally {
767
1111
  replayCaptureInternal = previousInternal;
@@ -773,6 +1117,79 @@ export async function flushSessionReplay(reason = "manual") {
773
1117
  if (uploaded && hasPendingReplayBatch(state)) {
774
1118
  flushQueuedReplayIfNeeded(state);
775
1119
  }
1120
+ if (definitiveClientErrorStatus !== null &&
1121
+ state.replayId === payload.replayId) {
1122
+ const rejectedOptions = state.options;
1123
+ const shouldRestartAfterConflict = definitiveClientErrorStatus === 409 &&
1124
+ state.active &&
1125
+ !isFinalFlushReason(reason) &&
1126
+ !state.automaticConflictRestartAttempted;
1127
+ if (shouldRestartAfterConflict) {
1128
+ state.automaticConflictRestartAttempted = true;
1129
+ }
1130
+ await stopSessionReplay("upload-rejected");
1131
+ // A 409 means this replay identity can no longer append safely (usually a
1132
+ // duplicated tab that inherited sessionStorage, or an old shared identity
1133
+ // still open during rollout). Do not leave a long-lived SPA tab silently
1134
+ // unrecorded until its next page load: restart rrweb under a fresh per-tab
1135
+ // id so it emits a new Meta + FullSnapshot stream. Limit this to one retry
1136
+ // until an upload succeeds; other definitive 4xx responses usually reflect
1137
+ // configuration/input errors and must not create a restart loop.
1138
+ let restartResult = null;
1139
+ if (shouldRestartAfterConflict && rejectedOptions) {
1140
+ restartResult = await restartSessionReplayAfterConflict(state, rejectedOptions, payload.sessionId);
1141
+ }
1142
+ // Rare recovery-path telemetry lets Analytics owners quantify conflicts
1143
+ // without recording the rejected replay id, URL, or any captured content.
1144
+ try {
1145
+ rejectedOptions?.onUploadRejected?.({
1146
+ status: definitiveClientErrorStatus,
1147
+ restartAttempted: shouldRestartAfterConflict,
1148
+ restartSucceeded: restartResult?.started === true,
1149
+ ...(restartResult?.reason
1150
+ ? { restartReason: restartResult.reason }
1151
+ : {}),
1152
+ });
1153
+ }
1154
+ catch {
1155
+ // best-effort telemetry must never interfere with recording recovery
1156
+ }
1157
+ }
1158
+ }
1159
+ /**
1160
+ * Restart a recorder whose replay identity was rejected without routing the
1161
+ * already-normalized options back through the public start API.
1162
+ *
1163
+ * The original recording already passed whole-session sampling. Re-entering
1164
+ * `startSessionReplay` here would ask `getOrCreateAnalyticsSessionId` again;
1165
+ * if the analytics session rotated while the upload was in flight, recovery
1166
+ * could be sampled out and leave a long-lived SPA tab silently unrecorded.
1167
+ * Keeping the original session id and accepted sampling decision also lets us
1168
+ * reuse the exact normalized console/network capture caps instead of relying
1169
+ * on internal option shapes continuing to round-trip through the public API.
1170
+ */
1171
+ async function restartSessionReplayAfterConflict(state, options, sessionId) {
1172
+ if (options.shouldStart && !options.shouldStart()) {
1173
+ return { started: false, reason: "disabled", sessionId, sampled: true };
1174
+ }
1175
+ const initialProperties = replayExtraProperties(options);
1176
+ if (options.requireSignedInUser && !replayUserEmail(initialProperties)) {
1177
+ return {
1178
+ started: false,
1179
+ reason: "missing-user-id",
1180
+ sessionId,
1181
+ sampled: true,
1182
+ };
1183
+ }
1184
+ if (!isUrlRecordable(window.location.href, options)) {
1185
+ return {
1186
+ started: false,
1187
+ reason: "url-blocked",
1188
+ sessionId,
1189
+ sampled: true,
1190
+ };
1191
+ }
1192
+ return startSessionReplayRecorder(state, options, sessionId, true, initialProperties);
776
1193
  }
777
1194
  function installUrlMonitor(state) {
778
1195
  if (!state.options || state.restoreUrlMonitor)
@@ -1503,7 +1920,31 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1503
1920
  if (normalized.shouldStart && !normalized.shouldStart()) {
1504
1921
  return { started: false, reason: "disabled", sessionId, sampled };
1505
1922
  }
1506
- const replaySession = getOrCreateReplaySession(sessionId);
1923
+ let replaySession = getOrCreateReplaySession(sessionId);
1924
+ const instanceNonce = generateReplayId();
1925
+ const { channel: replayChannel, probeClaim } = createReplayClaimChannel(state, instanceNonce);
1926
+ // Only a *resumed* id needs the duplicated-tab check -- a freshly minted
1927
+ // id can never collide with a recorder that's already running, so this
1928
+ // never adds startup latency for the common "new tab" case.
1929
+ if (replaySession.resumed && replayChannel) {
1930
+ const taken = await probeClaim(replaySession.replayId);
1931
+ if (taken) {
1932
+ const freshReplayId = generateReplayId();
1933
+ const freshStartedAtMs = Date.now();
1934
+ writeStoredReplaySession({
1935
+ sessionId,
1936
+ replayId: freshReplayId,
1937
+ startedAtMs: freshStartedAtMs,
1938
+ sequence: 0,
1939
+ });
1940
+ replaySession = {
1941
+ replayId: freshReplayId,
1942
+ startedAtMs: freshStartedAtMs,
1943
+ sequence: 0,
1944
+ resumed: false,
1945
+ };
1946
+ }
1947
+ }
1507
1948
  state.options = normalized;
1508
1949
  state.replayId = replaySession.replayId;
1509
1950
  state.startedAtMs = replaySession.startedAtMs;
@@ -1511,7 +1952,9 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1511
1952
  state.queue = [];
1512
1953
  state.queuedBytes = 0;
1513
1954
  state.retryBatches = [];
1955
+ state.resourceNodes.clear();
1514
1956
  state.stopRecorder = null;
1957
+ state.broadcastChannel = replayChannel;
1515
1958
  state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
1516
1959
  ? { ...initialProperties }
1517
1960
  : null;
@@ -1555,6 +1998,13 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1555
1998
  state.replayId = null;
1556
1999
  state.startedAtMs = null;
1557
2000
  state.lastAuthenticatedProperties = null;
2001
+ try {
2002
+ state.broadcastChannel?.close();
2003
+ }
2004
+ catch {
2005
+ // best-effort cleanup
2006
+ }
2007
+ state.broadcastChannel = null;
1558
2008
  return { started: false, reason: "record-failed", sessionId, sampled };
1559
2009
  }
1560
2010
  state.stopRecorder = stopRecorder;
@@ -1588,6 +2038,13 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
1588
2038
  state.replayId = null;
1589
2039
  state.startedAtMs = null;
1590
2040
  state.lastAuthenticatedProperties = null;
2041
+ try {
2042
+ state.broadcastChannel?.close();
2043
+ }
2044
+ catch {
2045
+ // best-effort cleanup
2046
+ }
2047
+ state.broadcastChannel = null;
1591
2048
  return { started: false, reason: "record-failed", sessionId, sampled };
1592
2049
  }
1593
2050
  }
@@ -1624,6 +2081,13 @@ export async function stopSessionReplay(reason = "manual") {
1624
2081
  }
1625
2082
  state.restoreUrlMonitor?.();
1626
2083
  state.removeLifecycleListeners?.();
2084
+ try {
2085
+ state.broadcastChannel?.close();
2086
+ }
2087
+ catch {
2088
+ // best-effort cleanup
2089
+ }
2090
+ state.broadcastChannel = null;
1627
2091
  await flushSessionReplay(reason);
1628
2092
  }
1629
2093
  export function maybeStartSessionReplay(options = {}) {
@@ -1634,9 +2098,10 @@ export function isSessionReplayActive() {
1634
2098
  }
1635
2099
  /**
1636
2100
  * The active session replay id when a recording is running, or the last one
1637
- * persisted for this analytics session in `localStorage`. First-party error
1638
- * capture uses this to tie each captured exception to the replay it happened
1639
- * in, so triage can jump straight to `/sessions/<recordingId>`.
2101
+ * persisted for this analytics session in this tab's `sessionStorage`.
2102
+ * First-party error capture uses this to tie each captured exception to the
2103
+ * replay it happened in, so triage can jump straight to
2104
+ * `/sessions/<recordingId>`.
1640
2105
  */
1641
2106
  export function getSessionReplayId() {
1642
2107
  const state = getState();