@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
@@ -11,6 +11,12 @@ type QueuedReplayEvent = {
11
11
  type: number | null;
12
12
  };
13
13
  type ReplayStopFn = () => void;
14
+ type ReplayResourceNode = {
15
+ tagName: string;
16
+ rel: string;
17
+ as: string;
18
+ type: string;
19
+ };
14
20
  export type SessionReplayUrlMatcher =
15
21
  | string
16
22
  | RegExp
@@ -68,6 +74,20 @@ interface SessionReplayState {
68
74
  restoreCaptures: (() => void) | null;
69
75
  options: NormalizedSessionReplayOptions | null;
70
76
  lastAuthenticatedProperties: Record<string, unknown> | null;
77
+ /** Resource tag metadata used to classify later rrweb attribute mutations. */
78
+ resourceNodes: Map<number, ReplayResourceNode>;
79
+ /**
80
+ * Prevents a permanently misconfigured endpoint from causing an automatic
81
+ * 409 -> restart -> 409 loop. A successful upload resets the allowance, so
82
+ * a later, independent sequence conflict can still recover in-place.
83
+ */
84
+ automaticConflictRestartAttempted: boolean;
85
+ /**
86
+ * Cross-tab "who is recording this replayId" channel, open for the
87
+ * lifetime of an active recording. See `getOrCreateReplaySession` for why
88
+ * this exists (duplicated-tab guard against a shared `replayId`).
89
+ */
90
+ broadcastChannel: BroadcastChannel | null;
71
91
  }
72
92
 
73
93
  interface StoredReplaySession {
@@ -77,6 +97,27 @@ interface StoredReplaySession {
77
97
  sequence?: number;
78
98
  }
79
99
 
100
+ /** Broadcast on `SESSION_REPLAY_BROADCAST_CHANNEL_NAME` by a tab resuming a
101
+ * stored replay session, to check whether another tab is already recording
102
+ * that same `replayId` (see the duplicated-tab guard in
103
+ * `getOrCreateReplaySession`'s doc comment). */
104
+ interface ReplayClaimMessage {
105
+ type: "an-replay-claim";
106
+ replayId: string;
107
+ instanceNonce: string;
108
+ }
109
+
110
+ /** Reply from a tab that is actively recording the claimed `replayId`. */
111
+ interface ReplayClaimTakenMessage {
112
+ type: "an-replay-claim-taken";
113
+ replayId: string;
114
+ /** Only the claimant with this nonce should yield the stored replay id.
115
+ * Optional while older recorder bundles can still be open during rollout. */
116
+ claimantNonce?: string;
117
+ }
118
+
119
+ type ReplayBroadcastMessage = ReplayClaimMessage | ReplayClaimTakenMessage;
120
+
80
121
  /** rrweb `sampling` shape (mousemove/scroll/media throttles, input strategy). */
81
122
  export type ReplayEventSampling = Record<string, unknown>;
82
123
 
@@ -107,6 +148,13 @@ export interface SessionReplayNetworkOptions {
107
148
  maxErrorBodyLength?: number;
108
149
  }
109
150
 
151
+ export interface SessionReplayUploadRejectedDetails {
152
+ status: number;
153
+ restartAttempted: boolean;
154
+ restartSucceeded: boolean;
155
+ restartReason?: SessionReplayStartResult["reason"];
156
+ }
157
+
110
158
  export interface SessionReplayOptions {
111
159
  enabled?: boolean;
112
160
  /** Rechecked immediately before rrweb starts to cancel deferred startup. */
@@ -157,6 +205,8 @@ export interface SessionReplayOptions {
157
205
  * disable, or an options object to override caps.
158
206
  */
159
207
  network?: boolean | SessionReplayNetworkOptions;
208
+ /** Rare recorder lifecycle signal; never includes replay content or URLs. */
209
+ onUploadRejected?: (details: SessionReplayUploadRejectedDetails) => void;
160
210
  extraProperties?:
161
211
  | Record<string, unknown>
162
212
  | (() => Record<string, unknown> | undefined);
@@ -209,6 +259,7 @@ interface NormalizedSessionReplayOptions {
209
259
  console: NormalizedCaptureOptions | null;
210
260
  /** Null disables network capture entirely. */
211
261
  network: NormalizedCaptureOptions | null;
262
+ onUploadRejected?: SessionReplayOptions["onUploadRejected"];
212
263
  extraProperties?: SessionReplayOptions["extraProperties"];
213
264
  shouldStart?: SessionReplayOptions["shouldStart"];
214
265
  }
@@ -266,6 +317,11 @@ const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
266
317
  const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
267
318
  const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
268
319
  const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
320
+ /** Cross-tab channel name used by the duplicated-tab claim guard. */
321
+ const SESSION_REPLAY_BROADCAST_CHANNEL_NAME = "agent-native-session-replay";
322
+ /** How long a resuming tab waits for a "someone else already owns this
323
+ * replayId" reply before proceeding to record with the resumed id. */
324
+ const SESSION_REPLAY_CLAIM_TIMEOUT_MS = 150;
269
325
 
270
326
  /** rrweb custom-event tag for captured console/window-error entries. */
271
327
  export const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
@@ -354,27 +410,64 @@ function getState(): SessionReplayState {
354
410
  restoreCaptures: null,
355
411
  options: null,
356
412
  lastAuthenticatedProperties: null,
413
+ resourceNodes: new Map(),
414
+ automaticConflictRestartAttempted: false,
415
+ broadcastChannel: null,
357
416
  };
358
417
  }
359
- return g[SESSION_REPLAY_STATE_KEY]!;
418
+ const state = g[SESSION_REPLAY_STATE_KEY]!;
419
+ // Keep Vite HMR safe when an older recorder state survives a module reload.
420
+ state.resourceNodes ??= new Map();
421
+ return state;
360
422
  }
361
423
 
362
- function safeStorageGet(key: string): string | null {
424
+ // The replay session record (replayId + sequence counter) lives in
425
+ // `sessionStorage`, not `localStorage`: `localStorage` is shared by every
426
+ // open tab of the origin, which would hand every tab the same `replayId` and
427
+ // the same sequence counter. See the guard comment above
428
+ // `getOrCreateReplaySession` for the corruption that causes.
429
+ function safeSessionStorageGet(key: string): string | null {
363
430
  try {
364
- return window.localStorage.getItem(key);
431
+ return window.sessionStorage.getItem(key);
365
432
  } catch {
366
433
  return null;
367
434
  }
368
435
  }
369
436
 
370
- function safeStorageSet(key: string, value: string): void {
437
+ function safeSessionStorageSet(key: string, value: string): void {
438
+ try {
439
+ window.sessionStorage.setItem(key, value);
440
+ } catch {
441
+ // private browsing / storage disabled -- replay still works for this page
442
+ }
443
+ }
444
+
445
+ function safeSessionStorageRemove(key: string): void {
371
446
  try {
372
- window.localStorage.setItem(key, value);
447
+ window.sessionStorage.removeItem(key);
373
448
  } catch {
374
449
  // private browsing / storage disabled -- replay still works for this page
375
450
  }
376
451
  }
377
452
 
453
+ let legacyLocalStorageReplaySessionCleared = false;
454
+
455
+ /**
456
+ * Best-effort, one-time removal of the pre-fix replay session record that
457
+ * used to live in `localStorage`. Never read from it -- adopting its
458
+ * `replayId` would recreate the exact shared-identity bug this file now
459
+ * avoids by using `sessionStorage` instead.
460
+ */
461
+ function clearLegacyLocalStorageReplaySession(): void {
462
+ if (legacyLocalStorageReplaySessionCleared) return;
463
+ legacyLocalStorageReplaySessionCleared = true;
464
+ try {
465
+ window.localStorage.removeItem(SESSION_REPLAY_ID_STORAGE_KEY);
466
+ } catch {
467
+ // best-effort only -- a stray legacy record is harmless once ignored
468
+ }
469
+ }
470
+
378
471
  function generateReplayId(): string {
379
472
  try {
380
473
  if (
@@ -390,7 +483,7 @@ function generateReplayId(): string {
390
483
  }
391
484
 
392
485
  function readStoredReplaySession(): StoredReplaySession | null {
393
- const raw = safeStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
486
+ const raw = safeSessionStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
394
487
  if (!raw) return null;
395
488
  try {
396
489
  const parsed = JSON.parse(raw) as StoredReplaySession;
@@ -402,14 +495,47 @@ function readStoredReplaySession(): StoredReplaySession | null {
402
495
  }
403
496
 
404
497
  function writeStoredReplaySession(value: StoredReplaySession): void {
405
- safeStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
498
+ safeSessionStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
406
499
  }
407
500
 
501
+ function removeStoredReplaySession(replayId: string): void {
502
+ if (readStoredReplaySession()?.replayId !== replayId) return;
503
+ safeSessionStorageRemove(SESSION_REPLAY_ID_STORAGE_KEY);
504
+ }
505
+
506
+ /**
507
+ * Per-tab replay identity is deliberate -- do not "fix" this by reading or
508
+ * writing the session record through `localStorage` again.
509
+ *
510
+ * `sessionStorage` is scoped to a single tab (and survives reloads/
511
+ * navigations within that tab, which is exactly the lifetime a recording
512
+ * needs). `localStorage` is shared by every open tab of the origin. If this
513
+ * record lived there, two tabs open to the same app would read/write the
514
+ * *same* `replayId` and the *same* sequence counter, so rrweb in each tab
515
+ * would record independently but upload chunks under one shared identity.
516
+ * The two interleaved DOM mutation streams get merged into a single
517
+ * recording server-side: mutations reference the other tab's node ids
518
+ * (broken CSS), the viewport/meta events reflect whichever tab resized last
519
+ * (wrong or ultra-wide viewport), and lost mousemove batches from the
520
+ * "other" tab's chunks read as a frozen cursor or a fake inactivity gap. A
521
+ * chunk-sequence collision with a different checksum gets rejected
522
+ * server-side (409) rather than merged, so one tab's batches are silently
523
+ * dropped -- there is no way to reconstruct or repair this at playback time.
524
+ * Keep this per-tab. A tab *duplicated* mid-session still shares a
525
+ * `sessionStorage` snapshot, which is what the `BroadcastChannel` claim
526
+ * check in `startSessionReplayRecorder` guards against.
527
+ */
408
528
  function getOrCreateReplaySession(sessionId: string): {
409
529
  replayId: string;
410
530
  startedAtMs: number;
411
531
  sequence: number;
532
+ /** True when resuming a session found in this tab's `sessionStorage`
533
+ * (e.g. a reload), as opposed to minting a brand-new id. Only the resumed
534
+ * case needs the duplicated-tab claim check -- a fresh id can never
535
+ * collide with anything already recording. */
536
+ resumed: boolean;
412
537
  } {
538
+ clearLegacyLocalStorageReplaySession();
413
539
  const parsed = readStoredReplaySession();
414
540
  if (parsed?.sessionId === sessionId && parsed.replayId) {
415
541
  const startedAtMs =
@@ -424,12 +550,127 @@ function getOrCreateReplaySession(sessionId: string): {
424
550
  parsed.sequence >= 0
425
551
  ? Math.floor(parsed.sequence)
426
552
  : 0;
427
- return { replayId: parsed.replayId, startedAtMs, sequence };
553
+ return { replayId: parsed.replayId, startedAtMs, sequence, resumed: true };
428
554
  }
429
555
  const replayId = generateReplayId();
430
556
  const startedAtMs = Date.now();
431
557
  writeStoredReplaySession({ sessionId, replayId, startedAtMs, sequence: 0 });
432
- return { replayId, startedAtMs, sequence: 0 };
558
+ return { replayId, startedAtMs, sequence: 0, resumed: false };
559
+ }
560
+
561
+ /**
562
+ * Open the cross-tab claim channel used to detect a *duplicated* tab (a
563
+ * browser "duplicate tab" or same-origin `window.open` copies
564
+ * `sessionStorage`, so two tabs can legitimately start with the same
565
+ * resumed `replayId`). Returns `null` when `BroadcastChannel` is
566
+ * unavailable -- callers must treat that as "skip the guard", never as an
567
+ * error.
568
+ */
569
+ function openReplayBroadcastChannel(): BroadcastChannel | null {
570
+ if (typeof BroadcastChannel === "undefined") return null;
571
+ try {
572
+ return new BroadcastChannel(SESSION_REPLAY_BROADCAST_CHANNEL_NAME);
573
+ } catch {
574
+ return null;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Wires up the duplicated-tab claim channel for one recorder lifetime.
580
+ * `respond` keeps listening for the whole life of the channel (any tab may
581
+ * later claim the `replayId` this tab is actively recording); `probeClaim`
582
+ * is used once, only when resuming a stored session, to ask "is anyone else
583
+ * already recording this id?" and wait up to
584
+ * `SESSION_REPLAY_CLAIM_TIMEOUT_MS` for a reply.
585
+ */
586
+ function createReplayClaimChannel(
587
+ state: SessionReplayState,
588
+ instanceNonce: string,
589
+ ): {
590
+ channel: BroadcastChannel | null;
591
+ probeClaim: (replayId: string) => Promise<boolean>;
592
+ } {
593
+ const channel = openReplayBroadcastChannel();
594
+ if (!channel) {
595
+ return { channel: null, probeClaim: async () => false };
596
+ }
597
+ let pending: {
598
+ replayId: string;
599
+ resolve: (taken: boolean) => void;
600
+ timer: number;
601
+ } | null = null;
602
+ channel.onmessage = (event: MessageEvent) => {
603
+ const data = event?.data as ReplayBroadcastMessage | undefined | null;
604
+ if (!data || typeof data !== "object") return;
605
+ if (data.type === "an-replay-claim") {
606
+ if (data.instanceNonce === instanceNonce) return;
607
+ const ownsReplayId = state.active && state.replayId === data.replayId;
608
+ const winsSimultaneousClaim =
609
+ pending?.replayId === data.replayId &&
610
+ instanceNonce.localeCompare(data.instanceNonce) < 0;
611
+ if (!ownsReplayId && !winsSimultaneousClaim) {
612
+ // If both duplicated tabs start simultaneously, deterministically
613
+ // yield to the lower nonce rather than letting both probes time out
614
+ // and record under the copied replay id.
615
+ if (
616
+ pending?.replayId === data.replayId &&
617
+ data.instanceNonce.localeCompare(instanceNonce) < 0
618
+ ) {
619
+ window.clearTimeout(pending.timer);
620
+ const resolve = pending.resolve;
621
+ pending = null;
622
+ resolve(true);
623
+ }
624
+ return;
625
+ }
626
+ try {
627
+ const reply: ReplayClaimTakenMessage = {
628
+ type: "an-replay-claim-taken",
629
+ replayId: data.replayId,
630
+ claimantNonce: data.instanceNonce,
631
+ };
632
+ channel.postMessage(reply);
633
+ } catch {
634
+ // best-effort -- a lost reply just means the duplicate tab resumes
635
+ // recording under the shared id; later 409s still protect the
636
+ // stream from getting corrupted merges.
637
+ }
638
+ return;
639
+ }
640
+ if (data.type === "an-replay-claim-taken") {
641
+ if (
642
+ pending &&
643
+ data.replayId === pending.replayId &&
644
+ (!data.claimantNonce || data.claimantNonce === instanceNonce)
645
+ ) {
646
+ window.clearTimeout(pending.timer);
647
+ const resolve = pending.resolve;
648
+ pending = null;
649
+ resolve(true);
650
+ }
651
+ }
652
+ };
653
+ const probeClaim = (replayId: string): Promise<boolean> =>
654
+ new Promise((resolve) => {
655
+ const timer = window.setTimeout(() => {
656
+ pending = null;
657
+ resolve(false);
658
+ }, SESSION_REPLAY_CLAIM_TIMEOUT_MS);
659
+ pending = { replayId, resolve, timer };
660
+ try {
661
+ const claim: ReplayClaimMessage = {
662
+ type: "an-replay-claim",
663
+ replayId,
664
+ instanceNonce,
665
+ };
666
+ channel.postMessage(claim);
667
+ } catch {
668
+ window.clearTimeout(timer);
669
+ pending = null;
670
+ resolve(false);
671
+ }
672
+ });
673
+ return { channel, probeClaim };
433
674
  }
434
675
 
435
676
  function persistReplaySequence(
@@ -658,6 +899,7 @@ function normalizeOptions(
658
899
  options.network,
659
900
  DEFAULT_MAX_NETWORK_EVENTS,
660
901
  ),
902
+ onUploadRejected: options.onUploadRejected,
661
903
  extraProperties: options.extraProperties,
662
904
  shouldStart: options.shouldStart,
663
905
  };
@@ -732,8 +974,159 @@ function scrubReplayValue(
732
974
  * the scrub into the single serialization pass avoids a separate deep-clone of
733
975
  * every emitted event (FullSnapshots are large DOM trees) on the hot path.
734
976
  */
735
- function scrubReplayReplacer(key: string, value: unknown): unknown {
736
- return typeof value === "string" ? scrubStringValue(key, value) : value;
977
+ const REPLAY_RESOURCE_LINK_RELS = new Set([
978
+ "stylesheet",
979
+ "icon",
980
+ "apple-touch-icon",
981
+ "mask-icon",
982
+ ]);
983
+ const REPLAY_RESOURCE_PRELOAD_TYPES = new Set([
984
+ "style",
985
+ "font",
986
+ "image",
987
+ "audio",
988
+ "video",
989
+ "track",
990
+ ]);
991
+ const REPLAY_RESOURCE_TAGS = new Set([
992
+ "img",
993
+ "source",
994
+ "video",
995
+ "audio",
996
+ "track",
997
+ "input",
998
+ "link",
999
+ ]);
1000
+ const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set<string>();
1001
+ const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
1002
+ const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
1003
+ const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
1004
+ const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
1005
+
1006
+ function replayAttributeString(
1007
+ attributes: Record<string, unknown>,
1008
+ key: string,
1009
+ ): string {
1010
+ return typeof attributes[key] === "string"
1011
+ ? attributes[key].toLowerCase()
1012
+ : "";
1013
+ }
1014
+
1015
+ function updateReplayResourceNode(
1016
+ current: ReplayResourceNode,
1017
+ attributes: Record<string, unknown>,
1018
+ ): ReplayResourceNode {
1019
+ return {
1020
+ tagName: current.tagName,
1021
+ rel: Object.hasOwn(attributes, "rel")
1022
+ ? replayAttributeString(attributes, "rel")
1023
+ : current.rel,
1024
+ as: Object.hasOwn(attributes, "as")
1025
+ ? replayAttributeString(attributes, "as")
1026
+ : current.as,
1027
+ type: Object.hasOwn(attributes, "type")
1028
+ ? replayAttributeString(attributes, "type")
1029
+ : current.type,
1030
+ };
1031
+ }
1032
+
1033
+ function replayPreservedResourceAttributes(
1034
+ node: ReplayResourceNode,
1035
+ ): ReadonlySet<string> {
1036
+ switch (node.tagName) {
1037
+ case "img":
1038
+ case "source":
1039
+ return REPLAY_SRCSET_ATTRIBUTES;
1040
+ case "video":
1041
+ return REPLAY_VIDEO_ATTRIBUTES;
1042
+ case "audio":
1043
+ case "track":
1044
+ return REPLAY_SRC_ATTRIBUTES;
1045
+ case "input":
1046
+ return node.type === "image"
1047
+ ? REPLAY_SRC_ATTRIBUTES
1048
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
1049
+ case "link": {
1050
+ const rels = node.rel.split(/\s+/);
1051
+ const isLoadBearingResource =
1052
+ rels.some((rel) => REPLAY_RESOURCE_LINK_RELS.has(rel)) ||
1053
+ (rels.includes("preload") &&
1054
+ REPLAY_RESOURCE_PRELOAD_TYPES.has(node.as));
1055
+ return isLoadBearingResource
1056
+ ? REPLAY_HREF_ATTRIBUTES
1057
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
1058
+ }
1059
+ default:
1060
+ return NO_REPLAY_RESOURCE_ATTRIBUTES;
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Build a path-aware replay serializer without cloning the rrweb event.
1066
+ *
1067
+ * Privacy still wins for Meta/navigation URLs, executable/embed URLs, anchor
1068
+ * hrefs, and custom console/network diagnostics. The narrow exception is
1069
+ * load-bearing stylesheet, font, image, and media attributes: changing those
1070
+ * signed URLs makes rrweb rebuild a page that never existed. JSON.stringify
1071
+ * calls a replacer for an `attributes` object before its children, so the
1072
+ * WeakMap lets the child callback recognize only that bag.
1073
+ */
1074
+ function createReplayScrubReplacer(
1075
+ resourceNodes: Map<number, ReplayResourceNode>,
1076
+ ): (this: unknown, key: string, value: unknown) => unknown {
1077
+ const preservedAttributes = new WeakMap<object, ReadonlySet<string>>();
1078
+
1079
+ return function replayScrubReplacer(
1080
+ this: unknown,
1081
+ key: string,
1082
+ value: unknown,
1083
+ ): unknown {
1084
+ if (key === "attributes" && value && typeof value === "object") {
1085
+ const attributes = value as Record<string, unknown>;
1086
+ const holder =
1087
+ this && typeof this === "object"
1088
+ ? (this as Record<string, unknown>)
1089
+ : undefined;
1090
+ const tagName =
1091
+ typeof holder?.tagName === "string" ? holder.tagName.toLowerCase() : "";
1092
+ const nodeId =
1093
+ typeof holder?.id === "number" && Number.isFinite(holder.id)
1094
+ ? holder.id
1095
+ : undefined;
1096
+ let resourceNode: ReplayResourceNode | undefined;
1097
+ if (tagName && REPLAY_RESOURCE_TAGS.has(tagName)) {
1098
+ resourceNode = updateReplayResourceNode(
1099
+ { tagName, rel: "", as: "", type: "" },
1100
+ attributes,
1101
+ );
1102
+ } else if (nodeId !== undefined && !tagName) {
1103
+ const current = resourceNodes.get(nodeId);
1104
+ if (current) {
1105
+ resourceNode = updateReplayResourceNode(current, attributes);
1106
+ }
1107
+ }
1108
+ if (nodeId !== undefined && resourceNode) {
1109
+ resourceNodes.set(nodeId, resourceNode);
1110
+ }
1111
+
1112
+ const resourceKeys = resourceNode
1113
+ ? replayPreservedResourceAttributes(resourceNode)
1114
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
1115
+
1116
+ if (resourceKeys.size > 0) preservedAttributes.set(value, resourceKeys);
1117
+ return value;
1118
+ }
1119
+
1120
+ if (
1121
+ typeof value === "string" &&
1122
+ this &&
1123
+ typeof this === "object" &&
1124
+ preservedAttributes.get(this)?.has(key.toLowerCase())
1125
+ ) {
1126
+ return value;
1127
+ }
1128
+ return typeof value === "string" ? scrubStringValue(key, value) : value;
1129
+ };
737
1130
  }
738
1131
 
739
1132
  /**
@@ -741,9 +1134,13 @@ function scrubReplayReplacer(key: string, value: unknown): unknown {
741
1134
  * directly on the queue and reused verbatim at flush, so each event is
742
1135
  * stringified exactly once (was: deep-clone + size-stringify + flush-stringify).
743
1136
  */
744
- function serializeReplayEvent(event: ReplayEvent): string {
1137
+ function serializeReplayEvent(
1138
+ event: ReplayEvent,
1139
+ resourceNodes: Map<number, ReplayResourceNode>,
1140
+ ): string {
745
1141
  try {
746
- return JSON.stringify(event, scrubReplayReplacer);
1142
+ if (event.type === 2) resourceNodes.clear();
1143
+ return JSON.stringify(event, createReplayScrubReplacer(resourceNodes));
747
1144
  } catch {
748
1145
  return "";
749
1146
  }
@@ -766,7 +1163,7 @@ function enqueueReplayEvent(
766
1163
  event: ReplayEvent,
767
1164
  ): void {
768
1165
  if (!state.options) return;
769
- const serialized = serializeReplayEvent(event);
1166
+ const serialized = serializeReplayEvent(event, state.resourceNodes);
770
1167
  if (!serialized) return;
771
1168
  const estimatedBytes = serialized.length;
772
1169
  if (
@@ -977,6 +1374,27 @@ function canUseReplayKeepalive(body: BodyInit): boolean {
977
1374
  return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
978
1375
  }
979
1376
 
1377
+ /** Thrown by `sendReplayUpload` on a non-ok HTTP response, carrying the
1378
+ * status so `flushSessionReplay` can tell a permanent client rejection
1379
+ * (e.g. a 409 checksum conflict, which can never succeed on retry) apart
1380
+ * from a transient failure worth retrying. */
1381
+ class ReplayUploadHttpError extends Error {
1382
+ readonly status: number;
1383
+ constructor(status: number) {
1384
+ super(`Session replay upload failed with HTTP ${status}`);
1385
+ this.name = "ReplayUploadHttpError";
1386
+ this.status = status;
1387
+ }
1388
+ }
1389
+
1390
+ /** 4xx statuses where retrying the exact same batch can never succeed --
1391
+ * e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
1392
+ * reject again. 408 (timeout) and 429 (rate limit) are excluded because a
1393
+ * later retry can plausibly succeed. */
1394
+ function isDefinitiveReplayUploadClientError(status: number): boolean {
1395
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
1396
+ }
1397
+
980
1398
  async function sendReplayUpload(
981
1399
  options: NormalizedSessionReplayOptions,
982
1400
  body: string,
@@ -992,9 +1410,7 @@ async function sendReplayUpload(
992
1410
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
993
1411
  });
994
1412
  if (!response.ok) {
995
- throw new Error(
996
- `Session replay upload failed with HTTP ${response.status}`,
997
- );
1413
+ throw new ReplayUploadHttpError(response.status);
998
1414
  }
999
1415
  return;
1000
1416
  }
@@ -1012,9 +1428,7 @@ async function sendReplayUpload(
1012
1428
  },
1013
1429
  });
1014
1430
  if (!response.ok) {
1015
- throw new Error(
1016
- `Session replay upload failed with HTTP ${response.status}`,
1017
- );
1431
+ throw new ReplayUploadHttpError(response.status);
1018
1432
  }
1019
1433
  }
1020
1434
 
@@ -1120,6 +1534,7 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1120
1534
  state.flushing = true;
1121
1535
  let uploaded = false;
1122
1536
  let reservedSequence = false;
1537
+ let definitiveClientErrorStatus: number | null = null;
1123
1538
  try {
1124
1539
  await sendReplayUpload(state.options, payload.body, {
1125
1540
  beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
@@ -1130,16 +1545,44 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1130
1545
  : undefined,
1131
1546
  });
1132
1547
  if (!reservedSequence) advanceReplaySequence(state, payload);
1548
+ state.automaticConflictRestartAttempted = false;
1133
1549
  uploaded = true;
1134
1550
  } catch (error) {
1135
1551
  if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
1136
- restoreReplayEvents(state, events);
1552
+ // A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
1553
+ // never succeed by retrying the exact same batch -- requeuing it would
1554
+ // just spin forever, blocking every later batch behind it (flushes are
1555
+ // FIFO via `retryBatches`). Drop it and move on instead.
1556
+ const isDefinitiveClientError =
1557
+ error instanceof ReplayUploadHttpError &&
1558
+ isDefinitiveReplayUploadClientError(error.status);
1559
+ if (isDefinitiveClientError) {
1560
+ // Continuing after a checksum/sequence conflict would reuse the same
1561
+ // rejected sequence forever. More importantly, advancing past it would
1562
+ // append mutations to a replay whose DOM stream may belong to another
1563
+ // tab. End this recorder and clear its persisted identity so the next
1564
+ // start creates a clean replay instead of producing corrupt playback.
1565
+ state.queue = [];
1566
+ state.queuedBytes = 0;
1567
+ state.retryBatches = [];
1568
+ removeStoredReplaySession(payload.replayId);
1569
+ definitiveClientErrorStatus = error.status;
1570
+ } else {
1571
+ restoreReplayEvents(state, events);
1572
+ }
1137
1573
  // Guard the recorder's own warning so console capture never records it
1138
1574
  // (a captured warning would enqueue an event and retrigger a flush).
1139
1575
  const previousInternal = replayCaptureInternal;
1140
1576
  replayCaptureInternal = true;
1141
1577
  try {
1142
- console.warn("[session-replay] upload failed", error);
1578
+ if (isDefinitiveClientError) {
1579
+ console.warn(
1580
+ `[session-replay] dropping upload (HTTP ${(error as ReplayUploadHttpError).status})`,
1581
+ error,
1582
+ );
1583
+ } else {
1584
+ console.warn("[session-replay] upload failed", error);
1585
+ }
1143
1586
  } finally {
1144
1587
  replayCaptureInternal = previousInternal;
1145
1588
  }
@@ -1149,6 +1592,101 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1149
1592
  if (uploaded && hasPendingReplayBatch(state)) {
1150
1593
  flushQueuedReplayIfNeeded(state);
1151
1594
  }
1595
+ if (
1596
+ definitiveClientErrorStatus !== null &&
1597
+ state.replayId === payload.replayId
1598
+ ) {
1599
+ const rejectedOptions = state.options;
1600
+ const shouldRestartAfterConflict =
1601
+ definitiveClientErrorStatus === 409 &&
1602
+ state.active &&
1603
+ !isFinalFlushReason(reason) &&
1604
+ !state.automaticConflictRestartAttempted;
1605
+ if (shouldRestartAfterConflict) {
1606
+ state.automaticConflictRestartAttempted = true;
1607
+ }
1608
+
1609
+ await stopSessionReplay("upload-rejected");
1610
+
1611
+ // A 409 means this replay identity can no longer append safely (usually a
1612
+ // duplicated tab that inherited sessionStorage, or an old shared identity
1613
+ // still open during rollout). Do not leave a long-lived SPA tab silently
1614
+ // unrecorded until its next page load: restart rrweb under a fresh per-tab
1615
+ // id so it emits a new Meta + FullSnapshot stream. Limit this to one retry
1616
+ // until an upload succeeds; other definitive 4xx responses usually reflect
1617
+ // configuration/input errors and must not create a restart loop.
1618
+ let restartResult: SessionReplayStartResult | null = null;
1619
+ if (shouldRestartAfterConflict && rejectedOptions) {
1620
+ restartResult = await restartSessionReplayAfterConflict(
1621
+ state,
1622
+ rejectedOptions,
1623
+ payload.sessionId,
1624
+ );
1625
+ }
1626
+
1627
+ // Rare recovery-path telemetry lets Analytics owners quantify conflicts
1628
+ // without recording the rejected replay id, URL, or any captured content.
1629
+ try {
1630
+ rejectedOptions?.onUploadRejected?.({
1631
+ status: definitiveClientErrorStatus,
1632
+ restartAttempted: shouldRestartAfterConflict,
1633
+ restartSucceeded: restartResult?.started === true,
1634
+ ...(restartResult?.reason
1635
+ ? { restartReason: restartResult.reason }
1636
+ : {}),
1637
+ });
1638
+ } catch {
1639
+ // best-effort telemetry must never interfere with recording recovery
1640
+ }
1641
+ }
1642
+ }
1643
+
1644
+ /**
1645
+ * Restart a recorder whose replay identity was rejected without routing the
1646
+ * already-normalized options back through the public start API.
1647
+ *
1648
+ * The original recording already passed whole-session sampling. Re-entering
1649
+ * `startSessionReplay` here would ask `getOrCreateAnalyticsSessionId` again;
1650
+ * if the analytics session rotated while the upload was in flight, recovery
1651
+ * could be sampled out and leave a long-lived SPA tab silently unrecorded.
1652
+ * Keeping the original session id and accepted sampling decision also lets us
1653
+ * reuse the exact normalized console/network capture caps instead of relying
1654
+ * on internal option shapes continuing to round-trip through the public API.
1655
+ */
1656
+ async function restartSessionReplayAfterConflict(
1657
+ state: SessionReplayState,
1658
+ options: NormalizedSessionReplayOptions,
1659
+ sessionId: string,
1660
+ ): Promise<SessionReplayStartResult> {
1661
+ if (options.shouldStart && !options.shouldStart()) {
1662
+ return { started: false, reason: "disabled", sessionId, sampled: true };
1663
+ }
1664
+
1665
+ const initialProperties = replayExtraProperties(options);
1666
+ if (options.requireSignedInUser && !replayUserEmail(initialProperties)) {
1667
+ return {
1668
+ started: false,
1669
+ reason: "missing-user-id",
1670
+ sessionId,
1671
+ sampled: true,
1672
+ };
1673
+ }
1674
+ if (!isUrlRecordable(window.location.href, options)) {
1675
+ return {
1676
+ started: false,
1677
+ reason: "url-blocked",
1678
+ sessionId,
1679
+ sampled: true,
1680
+ };
1681
+ }
1682
+
1683
+ return startSessionReplayRecorder(
1684
+ state,
1685
+ options,
1686
+ sessionId,
1687
+ true,
1688
+ initialProperties,
1689
+ );
1152
1690
  }
1153
1691
 
1154
1692
  function installUrlMonitor(state: SessionReplayState): void {
@@ -2043,7 +2581,34 @@ async function startSessionReplayRecorder(
2043
2581
  return { started: false, reason: "disabled", sessionId, sampled };
2044
2582
  }
2045
2583
 
2046
- const replaySession = getOrCreateReplaySession(sessionId);
2584
+ let replaySession = getOrCreateReplaySession(sessionId);
2585
+ const instanceNonce = generateReplayId();
2586
+ const { channel: replayChannel, probeClaim } = createReplayClaimChannel(
2587
+ state,
2588
+ instanceNonce,
2589
+ );
2590
+ // Only a *resumed* id needs the duplicated-tab check -- a freshly minted
2591
+ // id can never collide with a recorder that's already running, so this
2592
+ // never adds startup latency for the common "new tab" case.
2593
+ if (replaySession.resumed && replayChannel) {
2594
+ const taken = await probeClaim(replaySession.replayId);
2595
+ if (taken) {
2596
+ const freshReplayId = generateReplayId();
2597
+ const freshStartedAtMs = Date.now();
2598
+ writeStoredReplaySession({
2599
+ sessionId,
2600
+ replayId: freshReplayId,
2601
+ startedAtMs: freshStartedAtMs,
2602
+ sequence: 0,
2603
+ });
2604
+ replaySession = {
2605
+ replayId: freshReplayId,
2606
+ startedAtMs: freshStartedAtMs,
2607
+ sequence: 0,
2608
+ resumed: false,
2609
+ };
2610
+ }
2611
+ }
2047
2612
  state.options = normalized;
2048
2613
  state.replayId = replaySession.replayId;
2049
2614
  state.startedAtMs = replaySession.startedAtMs;
@@ -2051,7 +2616,9 @@ async function startSessionReplayRecorder(
2051
2616
  state.queue = [];
2052
2617
  state.queuedBytes = 0;
2053
2618
  state.retryBatches = [];
2619
+ state.resourceNodes.clear();
2054
2620
  state.stopRecorder = null;
2621
+ state.broadcastChannel = replayChannel;
2055
2622
  state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
2056
2623
  ? { ...initialProperties }
2057
2624
  : null;
@@ -2096,6 +2663,12 @@ async function startSessionReplayRecorder(
2096
2663
  state.replayId = null;
2097
2664
  state.startedAtMs = null;
2098
2665
  state.lastAuthenticatedProperties = null;
2666
+ try {
2667
+ state.broadcastChannel?.close();
2668
+ } catch {
2669
+ // best-effort cleanup
2670
+ }
2671
+ state.broadcastChannel = null;
2099
2672
  return { started: false, reason: "record-failed", sessionId, sampled };
2100
2673
  }
2101
2674
  state.stopRecorder = stopRecorder;
@@ -2133,6 +2706,12 @@ async function startSessionReplayRecorder(
2133
2706
  state.replayId = null;
2134
2707
  state.startedAtMs = null;
2135
2708
  state.lastAuthenticatedProperties = null;
2709
+ try {
2710
+ state.broadcastChannel?.close();
2711
+ } catch {
2712
+ // best-effort cleanup
2713
+ }
2714
+ state.broadcastChannel = null;
2136
2715
  return { started: false, reason: "record-failed", sessionId, sampled };
2137
2716
  }
2138
2717
  }
@@ -2167,6 +2746,12 @@ export async function stopSessionReplay(reason = "manual"): Promise<void> {
2167
2746
  }
2168
2747
  state.restoreUrlMonitor?.();
2169
2748
  state.removeLifecycleListeners?.();
2749
+ try {
2750
+ state.broadcastChannel?.close();
2751
+ } catch {
2752
+ // best-effort cleanup
2753
+ }
2754
+ state.broadcastChannel = null;
2170
2755
  await flushSessionReplay(reason);
2171
2756
  }
2172
2757
 
@@ -2182,9 +2767,10 @@ export function isSessionReplayActive(): boolean {
2182
2767
 
2183
2768
  /**
2184
2769
  * The active session replay id when a recording is running, or the last one
2185
- * persisted for this analytics session in `localStorage`. First-party error
2186
- * capture uses this to tie each captured exception to the replay it happened
2187
- * in, so triage can jump straight to `/sessions/<recordingId>`.
2770
+ * persisted for this analytics session in this tab's `sessionStorage`.
2771
+ * First-party error capture uses this to tie each captured exception to the
2772
+ * replay it happened in, so triage can jump straight to
2773
+ * `/sessions/<recordingId>`.
2188
2774
  */
2189
2775
  export function getSessionReplayId(): string | null {
2190
2776
  const state = getState();