@agent-native/core 0.98.3 → 0.98.5

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 (230) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +29 -0
  3. package/corpus/core/docs/content/durable-background-runs.mdx +37 -0
  4. package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +2 -0
  5. package/corpus/core/docs/content/locales/de-DE/tracking.mdx +2 -0
  6. package/corpus/core/docs/content/locales/es-ES/tracking.mdx +2 -0
  7. package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +4 -2
  8. package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +2 -0
  9. package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +2 -0
  10. package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +2 -0
  11. package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +2 -0
  12. package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +2 -0
  13. package/corpus/core/docs/content/locales/zh-TW/durable-background-runs.mdx +14 -0
  14. package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +2 -0
  15. package/corpus/core/docs/content/tracking.mdx +2 -0
  16. package/corpus/core/package.json +1 -1
  17. package/corpus/core/src/agent/durable-background.ts +16 -16
  18. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +25 -8
  19. package/corpus/core/src/agent/engine/anthropic-engine.ts +17 -3
  20. package/corpus/core/src/agent/engine/output-tokens.ts +6 -6
  21. package/corpus/core/src/agent/production-agent.ts +200 -27
  22. package/corpus/core/src/agent/run-manager.ts +81 -1
  23. package/corpus/core/src/agent/run-store.ts +301 -41
  24. package/corpus/core/src/client/AgentPanel.tsx +6 -2
  25. package/corpus/core/src/client/AssistantChat.tsx +364 -69
  26. package/corpus/core/src/client/agent-chat-adapter.ts +169 -1
  27. package/corpus/core/src/client/analytics.ts +12 -0
  28. package/corpus/core/src/client/chat/message-components.tsx +3 -0
  29. package/corpus/core/src/client/chat/tool-call-display.tsx +20 -36
  30. package/corpus/core/src/client/conversation/use-near-bottom-autoscroll.ts +7 -0
  31. package/corpus/core/src/client/extensions/AgentNativeExtensionFrame.tsx +2 -0
  32. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +2 -0
  33. package/corpus/core/src/client/extensions/ExtensionEditor.tsx +9 -2
  34. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +2 -0
  35. package/corpus/core/src/client/extensions/InlineExtensionFrame.tsx +2 -0
  36. package/corpus/core/src/client/extensions/portable-extension.ts +2 -0
  37. package/corpus/core/src/client/session-replay.ts +971 -53
  38. package/corpus/core/src/client/settings/DemoModeSection.tsx +3 -3
  39. package/corpus/core/src/client/sse-event-processor.ts +43 -1
  40. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  41. package/corpus/core/src/demo/config.ts +6 -6
  42. package/corpus/core/src/demo/fetch-interceptor.ts +34 -2
  43. package/corpus/core/src/demo/redact.ts +71 -158
  44. package/corpus/core/src/extensions/html-shell.ts +3 -0
  45. package/corpus/core/src/extensions/session-replay-iframe.ts +131 -0
  46. package/corpus/core/src/scripts/agent-engines/list-agent-engines.ts +1 -20
  47. package/corpus/core/src/session-replay-iframe-protocol.ts +58 -0
  48. package/corpus/core/src/shared/reasoning-effort.ts +35 -0
  49. package/corpus/core/src/styles/agent-conversation.css +26 -0
  50. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
  51. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +67 -16
  52. package/corpus/templates/analytics/AGENTS.md +12 -0
  53. package/corpus/templates/analytics/actions/get-error-issue.ts +1 -0
  54. package/corpus/templates/analytics/actions/get-session-replay-events.ts +1 -0
  55. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +1 -0
  56. package/corpus/templates/analytics/actions/list-error-issues.ts +1 -0
  57. package/corpus/templates/analytics/actions/list-session-recordings.ts +1 -0
  58. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -1
  59. package/corpus/templates/analytics/app/global.css +6 -64
  60. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -4
  61. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +143 -121
  62. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +34 -12
  63. package/corpus/templates/analytics/changelog/2026-07-12-analytics-chat-retries-before-showing-a-generic-no-data-mess.md +6 -0
  64. package/corpus/templates/analytics/changelog/2026-07-12-analytics-sessions-search-no-longer-loses-fast-keystrokes-an.md +6 -0
  65. package/corpus/templates/analytics/changelog/2026-07-12-chart-selection-respects-chat-state.md +6 -0
  66. package/corpus/templates/analytics/changelog/2026-07-12-demo-mode-anonymizes-error-reporting-emails.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-07-12-fixed-session-replays-that-appeared-ultra-wide-hid-recorded-.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-load-faster-in-demo-mode-while-visitor-email.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-show-iframe-content.md +6 -0
  70. package/corpus/templates/analytics/netlify.toml +0 -3
  71. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +14 -0
  72. package/corpus/templates/analytics/server/lib/error-capture.ts +59 -2
  73. package/corpus/templates/analytics/server/lib/real-data-actions.ts +24 -0
  74. package/corpus/templates/analytics/server/lib/session-replay.ts +2 -9
  75. package/corpus/templates/analytics/server/plugins/agent-chat.ts +39 -6
  76. package/corpus/templates/assets/app/global.css +1 -1
  77. package/corpus/templates/brain/app/global.css +2 -2
  78. package/corpus/templates/brain/changelog/2026-07-12-fix-long-response-progress.md +6 -0
  79. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +11 -2
  80. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +14 -2
  81. package/corpus/templates/calendar/changelog/2026-07-12-calendar-responses-can-now-be-saved-with-command-enter-or-ct.md +6 -0
  82. package/corpus/templates/forms/app/components/CloudUpgrade.tsx +1 -1
  83. package/corpus/templates/forms/app/components/ThemeToggle.tsx +22 -6
  84. package/corpus/templates/forms/app/components/builder/FieldPropertiesPanel.tsx +5 -5
  85. package/corpus/templates/forms/app/components/builder/FieldRenderer.tsx +3 -3
  86. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +16 -16
  87. package/corpus/templates/forms/app/global.css +110 -5
  88. package/corpus/templates/forms/app/pages/AskPage.tsx +3 -3
  89. package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +134 -52
  90. package/corpus/templates/forms/app/pages/FormFillPage.tsx +44 -11
  91. package/corpus/templates/forms/app/pages/FormsListPage.tsx +42 -24
  92. package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +28 -15
  93. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +38 -15
  94. package/corpus/templates/forms/app/routes/_app.settings.tsx +1 -1
  95. package/corpus/templates/forms/changelog/2026-07-12-form-builder-surfaces-are-calmer-fields-stay-stable-on-hover.md +6 -0
  96. package/corpus/templates/forms/changelog/2026-07-12-smoother-more-tactile-controls-throughout-animated-icon-and-.md +6 -0
  97. package/corpus/templates/mail/app/components/email/EmailThread.tsx +21 -13
  98. package/corpus/templates/mail/app/components/email/email-iframe-document.ts +18 -0
  99. package/dist/agent/durable-background.d.ts +10 -10
  100. package/dist/agent/durable-background.d.ts.map +1 -1
  101. package/dist/agent/durable-background.js +16 -16
  102. package/dist/agent/durable-background.js.map +1 -1
  103. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  104. package/dist/agent/engine/ai-sdk-engine.js +19 -8
  105. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  106. package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
  107. package/dist/agent/engine/anthropic-engine.js +11 -3
  108. package/dist/agent/engine/anthropic-engine.js.map +1 -1
  109. package/dist/agent/engine/output-tokens.js +6 -6
  110. package/dist/agent/engine/output-tokens.js.map +1 -1
  111. package/dist/agent/production-agent.d.ts +52 -0
  112. package/dist/agent/production-agent.d.ts.map +1 -1
  113. package/dist/agent/production-agent.js +154 -26
  114. package/dist/agent/production-agent.js.map +1 -1
  115. package/dist/agent/run-manager.d.ts +9 -0
  116. package/dist/agent/run-manager.d.ts.map +1 -1
  117. package/dist/agent/run-manager.js +71 -1
  118. package/dist/agent/run-manager.js.map +1 -1
  119. package/dist/agent/run-store.d.ts +9 -0
  120. package/dist/agent/run-store.d.ts.map +1 -1
  121. package/dist/agent/run-store.js +243 -48
  122. package/dist/agent/run-store.js.map +1 -1
  123. package/dist/client/AgentPanel.d.ts.map +1 -1
  124. package/dist/client/AgentPanel.js +6 -2
  125. package/dist/client/AgentPanel.js.map +1 -1
  126. package/dist/client/AssistantChat.d.ts +21 -0
  127. package/dist/client/AssistantChat.d.ts.map +1 -1
  128. package/dist/client/AssistantChat.js +298 -67
  129. package/dist/client/AssistantChat.js.map +1 -1
  130. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  131. package/dist/client/agent-chat-adapter.js +146 -1
  132. package/dist/client/agent-chat-adapter.js.map +1 -1
  133. package/dist/client/analytics.d.ts.map +1 -1
  134. package/dist/client/analytics.js +11 -0
  135. package/dist/client/analytics.js.map +1 -1
  136. package/dist/client/chat/message-components.d.ts.map +1 -1
  137. package/dist/client/chat/message-components.js +2 -1
  138. package/dist/client/chat/message-components.js.map +1 -1
  139. package/dist/client/chat/tool-call-display.d.ts +3 -1
  140. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  141. package/dist/client/chat/tool-call-display.js +13 -37
  142. package/dist/client/chat/tool-call-display.js.map +1 -1
  143. package/dist/client/conversation/use-near-bottom-autoscroll.d.ts +1 -0
  144. package/dist/client/conversation/use-near-bottom-autoscroll.d.ts.map +1 -1
  145. package/dist/client/conversation/use-near-bottom-autoscroll.js +6 -0
  146. package/dist/client/conversation/use-near-bottom-autoscroll.js.map +1 -1
  147. package/dist/client/extensions/AgentNativeExtensionFrame.d.ts.map +1 -1
  148. package/dist/client/extensions/AgentNativeExtensionFrame.js +2 -1
  149. package/dist/client/extensions/AgentNativeExtensionFrame.js.map +1 -1
  150. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  151. package/dist/client/extensions/EmbeddedExtension.js +2 -1
  152. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  153. package/dist/client/extensions/ExtensionEditor.d.ts.map +1 -1
  154. package/dist/client/extensions/ExtensionEditor.js +5 -2
  155. package/dist/client/extensions/ExtensionEditor.js.map +1 -1
  156. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  157. package/dist/client/extensions/ExtensionViewer.js +2 -1
  158. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  159. package/dist/client/extensions/InlineExtensionFrame.d.ts.map +1 -1
  160. package/dist/client/extensions/InlineExtensionFrame.js +2 -1
  161. package/dist/client/extensions/InlineExtensionFrame.js.map +1 -1
  162. package/dist/client/extensions/portable-extension.d.ts.map +1 -1
  163. package/dist/client/extensions/portable-extension.js +2 -0
  164. package/dist/client/extensions/portable-extension.js.map +1 -1
  165. package/dist/client/session-replay.d.ts +12 -3
  166. package/dist/client/session-replay.d.ts.map +1 -1
  167. package/dist/client/session-replay.js +793 -49
  168. package/dist/client/session-replay.js.map +1 -1
  169. package/dist/client/settings/DemoModeSection.js +1 -1
  170. package/dist/client/settings/DemoModeSection.js.map +1 -1
  171. package/dist/client/sse-event-processor.d.ts +5 -0
  172. package/dist/client/sse-event-processor.d.ts.map +1 -1
  173. package/dist/client/sse-event-processor.js +37 -1
  174. package/dist/client/sse-event-processor.js.map +1 -1
  175. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  176. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  177. package/dist/demo/config.js +6 -6
  178. package/dist/demo/config.js.map +1 -1
  179. package/dist/demo/fetch-interceptor.d.ts +1 -0
  180. package/dist/demo/fetch-interceptor.d.ts.map +1 -1
  181. package/dist/demo/fetch-interceptor.js +28 -2
  182. package/dist/demo/fetch-interceptor.js.map +1 -1
  183. package/dist/demo/redact.d.ts +14 -7
  184. package/dist/demo/redact.d.ts.map +1 -1
  185. package/dist/demo/redact.js +27 -155
  186. package/dist/demo/redact.js.map +1 -1
  187. package/dist/extensions/html-shell.d.ts.map +1 -1
  188. package/dist/extensions/html-shell.js +2 -0
  189. package/dist/extensions/html-shell.js.map +1 -1
  190. package/dist/extensions/session-replay-iframe.d.ts +10 -0
  191. package/dist/extensions/session-replay-iframe.d.ts.map +1 -0
  192. package/dist/extensions/session-replay-iframe.js +121 -0
  193. package/dist/extensions/session-replay-iframe.js.map +1 -0
  194. package/dist/notifications/routes.d.ts +1 -1
  195. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  196. package/dist/scripts/agent-engines/list-agent-engines.d.ts.map +1 -1
  197. package/dist/scripts/agent-engines/list-agent-engines.js +1 -19
  198. package/dist/scripts/agent-engines/list-agent-engines.js.map +1 -1
  199. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  200. package/dist/server/transcribe-voice.d.ts +1 -1
  201. package/dist/session-replay-iframe-protocol.d.ts +36 -0
  202. package/dist/session-replay-iframe-protocol.d.ts.map +1 -0
  203. package/dist/session-replay-iframe-protocol.js +24 -0
  204. package/dist/session-replay-iframe-protocol.js.map +1 -0
  205. package/dist/shared/reasoning-effort.d.ts +11 -0
  206. package/dist/shared/reasoning-effort.d.ts.map +1 -1
  207. package/dist/shared/reasoning-effort.js +36 -0
  208. package/dist/shared/reasoning-effort.js.map +1 -1
  209. package/dist/styles/agent-conversation.css +26 -0
  210. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
  211. package/docs/content/durable-background-runs.mdx +37 -0
  212. package/docs/content/locales/ar-SA/tracking.mdx +2 -0
  213. package/docs/content/locales/de-DE/tracking.mdx +2 -0
  214. package/docs/content/locales/es-ES/tracking.mdx +2 -0
  215. package/docs/content/locales/fr-FR/tracking.mdx +4 -2
  216. package/docs/content/locales/hi-IN/tracking.mdx +2 -0
  217. package/docs/content/locales/ja-JP/tracking.mdx +2 -0
  218. package/docs/content/locales/ko-KR/tracking.mdx +2 -0
  219. package/docs/content/locales/pt-BR/tracking.mdx +2 -0
  220. package/docs/content/locales/zh-CN/tracking.mdx +2 -0
  221. package/docs/content/locales/zh-TW/durable-background-runs.mdx +14 -0
  222. package/docs/content/locales/zh-TW/tracking.mdx +2 -0
  223. package/docs/content/tracking.mdx +2 -0
  224. package/package.json +2 -2
  225. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
  226. package/corpus/core/src/observability/hosted-model-experiment.ts +0 -118
  227. package/dist/observability/hosted-model-experiment.d.ts +0 -39
  228. package/dist/observability/hosted-model-experiment.d.ts.map +0 -1
  229. package/dist/observability/hosted-model-experiment.js +0 -90
  230. package/dist/observability/hosted-model-experiment.js.map +0 -1
@@ -1,3 +1,12 @@
1
+ import {
2
+ SESSION_REPLAY_IFRAME_ATTRIBUTE,
3
+ SESSION_REPLAY_IFRAME_PROBE,
4
+ SESSION_REPLAY_IFRAME_START,
5
+ SESSION_REPLAY_IFRAME_STOP,
6
+ type SessionReplayIframePrivacyOptions,
7
+ type SessionReplayIframeStartMessage,
8
+ type SessionReplayIframeStopMessage,
9
+ } from "../session-replay-iframe-protocol.js";
1
10
  import {
2
11
  getOrCreateAnalyticsAnonymousId,
3
12
  getOrCreateAnalyticsSessionId,
@@ -7,10 +16,17 @@ import { scrubUrl } from "./url-scrub.js";
7
16
  type ReplayEvent = Record<string, unknown>;
8
17
  type QueuedReplayEvent = {
9
18
  json: string;
19
+ byteLength: number;
10
20
  timestampMs: number;
11
21
  type: number | null;
12
22
  };
13
23
  type ReplayStopFn = () => void;
24
+ type ReplayResourceNode = {
25
+ tagName: string;
26
+ rel: string;
27
+ as: string;
28
+ type: string;
29
+ };
14
30
  export type SessionReplayUrlMatcher =
15
31
  | string
16
32
  | RegExp
@@ -59,15 +75,37 @@ interface SessionReplayState {
59
75
  flushTimer: number | null;
60
76
  maxDurationTimer: number | null;
61
77
  flushing: boolean;
78
+ /** Highest-priority flush requested while an upload was already in flight. */
79
+ pendingFlushReason: string | null;
80
+ /** Callers awaiting the coalesced flush tail. */
81
+ pendingFlushWaiters: Array<() => void>;
82
+ /** Drop incremental events until a new FullSnapshot can re-anchor the DOM. */
83
+ awaitingFullSnapshot: boolean;
62
84
  stopRecorder: ReplayStopFn | null;
63
85
  restoreUrlMonitor: (() => void) | null;
64
86
  removeLifecycleListeners: (() => void) | null;
87
+ /** Stops the cooperative child-iframe bridge and notifies active children. */
88
+ restoreIframeBridge: (() => void) | null;
65
89
  /** rrweb's `record.addCustomEvent`, captured at start (null when absent). */
66
90
  addCustomEvent: ((tag: string, payload: unknown) => void) | null;
67
91
  /** Uninstalls console/network interceptors and flushes pending duplicates. */
68
92
  restoreCaptures: (() => void) | null;
69
93
  options: NormalizedSessionReplayOptions | null;
70
94
  lastAuthenticatedProperties: Record<string, unknown> | null;
95
+ /** Resource tag metadata used to classify later rrweb attribute mutations. */
96
+ resourceNodes: Map<number, ReplayResourceNode>;
97
+ /**
98
+ * Prevents a permanently misconfigured endpoint from causing an automatic
99
+ * 409 -> restart -> 409 loop. A successful upload resets the allowance, so
100
+ * a later, independent sequence conflict can still recover in-place.
101
+ */
102
+ automaticConflictRestartAttempted: boolean;
103
+ /**
104
+ * Cross-tab "who is recording this replayId" channel, open for the
105
+ * lifetime of an active recording. See `getOrCreateReplaySession` for why
106
+ * this exists (duplicated-tab guard against a shared `replayId`).
107
+ */
108
+ broadcastChannel: BroadcastChannel | null;
71
109
  }
72
110
 
73
111
  interface StoredReplaySession {
@@ -77,6 +115,27 @@ interface StoredReplaySession {
77
115
  sequence?: number;
78
116
  }
79
117
 
118
+ /** Broadcast on `SESSION_REPLAY_BROADCAST_CHANNEL_NAME` by a tab resuming a
119
+ * stored replay session, to check whether another tab is already recording
120
+ * that same `replayId` (see the duplicated-tab guard in
121
+ * `getOrCreateReplaySession`'s doc comment). */
122
+ interface ReplayClaimMessage {
123
+ type: "an-replay-claim";
124
+ replayId: string;
125
+ instanceNonce: string;
126
+ }
127
+
128
+ /** Reply from a tab that is actively recording the claimed `replayId`. */
129
+ interface ReplayClaimTakenMessage {
130
+ type: "an-replay-claim-taken";
131
+ replayId: string;
132
+ /** Only the claimant with this nonce should yield the stored replay id.
133
+ * Optional while older recorder bundles can still be open during rollout. */
134
+ claimantNonce?: string;
135
+ }
136
+
137
+ type ReplayBroadcastMessage = ReplayClaimMessage | ReplayClaimTakenMessage;
138
+
80
139
  /** rrweb `sampling` shape (mousemove/scroll/media throttles, input strategy). */
81
140
  export type ReplayEventSampling = Record<string, unknown>;
82
141
 
@@ -107,6 +166,13 @@ export interface SessionReplayNetworkOptions {
107
166
  maxErrorBodyLength?: number;
108
167
  }
109
168
 
169
+ export interface SessionReplayUploadRejectedDetails {
170
+ status: number;
171
+ restartAttempted: boolean;
172
+ restartSucceeded: boolean;
173
+ restartReason?: SessionReplayStartResult["reason"];
174
+ }
175
+
110
176
  export interface SessionReplayOptions {
111
177
  enabled?: boolean;
112
178
  /** Rechecked immediately before rrweb starts to cancel deferred startup. */
@@ -157,6 +223,8 @@ export interface SessionReplayOptions {
157
223
  * disable, or an options object to override caps.
158
224
  */
159
225
  network?: boolean | SessionReplayNetworkOptions;
226
+ /** Rare recorder lifecycle signal; never includes replay content or URLs. */
227
+ onUploadRejected?: (details: SessionReplayUploadRejectedDetails) => void;
160
228
  extraProperties?:
161
229
  | Record<string, unknown>
162
230
  | (() => Record<string, unknown> | undefined);
@@ -209,6 +277,7 @@ interface NormalizedSessionReplayOptions {
209
277
  console: NormalizedCaptureOptions | null;
210
278
  /** Null disables network capture entirely. */
211
279
  network: NormalizedCaptureOptions | null;
280
+ onUploadRejected?: SessionReplayOptions["onUploadRejected"];
212
281
  extraProperties?: SessionReplayOptions["extraProperties"];
213
282
  shouldStart?: SessionReplayOptions["shouldStart"];
214
283
  }
@@ -240,7 +309,9 @@ const SESSION_REPLAY_STATE_KEY = Symbol.for(
240
309
  "agent-native.client.sessionReplay",
241
310
  );
242
311
  const SESSION_REPLAY_ID_STORAGE_KEY = "agent-native.session_replay_id";
312
+ const SESSION_REPLAY_IFRAME_BLOCK_SELECTOR = `iframe[${SESSION_REPLAY_IFRAME_ATTRIBUTE}]`;
243
313
  const DEFAULT_BLOCK_SELECTOR = [
314
+ SESSION_REPLAY_IFRAME_BLOCK_SELECTOR,
244
315
  "[data-sensitive]",
245
316
  "[data-an-block]",
246
317
  "[data-an-private]",
@@ -260,12 +331,35 @@ const DEFAULT_BLOCK_SELECTOR = [
260
331
  const DEFAULT_IGNORE_SELECTOR = ".an-ignore, [data-an-ignore]";
261
332
  const DEFAULT_MASK_TEXT_CLASS = "an-mask";
262
333
  const DEFAULT_MASK_TEXT_SELECTOR = "[data-an-mask]";
334
+ const DEFAULT_MASK_INPUT_OPTIONS: Record<string, boolean> = {
335
+ color: true,
336
+ date: true,
337
+ "datetime-local": true,
338
+ email: true,
339
+ month: true,
340
+ number: true,
341
+ password: true,
342
+ range: true,
343
+ search: true,
344
+ tel: true,
345
+ text: true,
346
+ time: true,
347
+ url: true,
348
+ week: true,
349
+ };
263
350
  const DEFAULT_FLUSH_INTERVAL_MS = 5000;
264
351
  const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000;
265
352
  const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
266
353
  const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
267
354
  const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
355
+ const REPLAY_TEXT_ENCODER =
356
+ typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
268
357
  const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
358
+ /** Cross-tab channel name used by the duplicated-tab claim guard. */
359
+ const SESSION_REPLAY_BROADCAST_CHANNEL_NAME = "agent-native-session-replay";
360
+ /** How long a resuming tab waits for a "someone else already owns this
361
+ * replayId" reply before proceeding to record with the resumed id. */
362
+ const SESSION_REPLAY_CLAIM_TIMEOUT_MS = 150;
269
363
 
270
364
  /** rrweb custom-event tag for captured console/window-error entries. */
271
365
  export const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
@@ -347,34 +441,79 @@ function getState(): SessionReplayState {
347
441
  flushTimer: null,
348
442
  maxDurationTimer: null,
349
443
  flushing: false,
444
+ pendingFlushReason: null,
445
+ pendingFlushWaiters: [],
446
+ awaitingFullSnapshot: false,
350
447
  stopRecorder: null,
351
448
  restoreUrlMonitor: null,
352
449
  removeLifecycleListeners: null,
450
+ restoreIframeBridge: null,
353
451
  addCustomEvent: null,
354
452
  restoreCaptures: null,
355
453
  options: null,
356
454
  lastAuthenticatedProperties: null,
455
+ resourceNodes: new Map(),
456
+ automaticConflictRestartAttempted: false,
457
+ broadcastChannel: null,
357
458
  };
358
459
  }
359
- return g[SESSION_REPLAY_STATE_KEY]!;
460
+ const state = g[SESSION_REPLAY_STATE_KEY]!;
461
+ // Keep Vite HMR safe when an older recorder state survives a module reload.
462
+ state.resourceNodes ??= new Map();
463
+ state.restoreIframeBridge ??= null;
464
+ state.pendingFlushReason ??= null;
465
+ state.pendingFlushWaiters ??= [];
466
+ state.awaitingFullSnapshot ??= false;
467
+ return state;
468
+ }
469
+
470
+ // The replay session record (replayId + sequence counter) lives in
471
+ // `sessionStorage`, not `localStorage`: `localStorage` is shared by every
472
+ // open tab of the origin, which would hand every tab the same `replayId` and
473
+ // the same sequence counter. See the guard comment above
474
+ // `getOrCreateReplaySession` for the corruption that causes.
475
+ function safeSessionStorageGet(key: string): string | null {
476
+ try {
477
+ return window.sessionStorage.getItem(key);
478
+ } catch {
479
+ return null;
480
+ }
360
481
  }
361
482
 
362
- function safeStorageGet(key: string): string | null {
483
+ function safeSessionStorageSet(key: string, value: string): void {
363
484
  try {
364
- return window.localStorage.getItem(key);
485
+ window.sessionStorage.setItem(key, value);
365
486
  } catch {
366
- return null;
487
+ // private browsing / storage disabled -- replay still works for this page
367
488
  }
368
489
  }
369
490
 
370
- function safeStorageSet(key: string, value: string): void {
491
+ function safeSessionStorageRemove(key: string): void {
371
492
  try {
372
- window.localStorage.setItem(key, value);
493
+ window.sessionStorage.removeItem(key);
373
494
  } catch {
374
495
  // private browsing / storage disabled -- replay still works for this page
375
496
  }
376
497
  }
377
498
 
499
+ let legacyLocalStorageReplaySessionCleared = false;
500
+
501
+ /**
502
+ * Best-effort, one-time removal of the pre-fix replay session record that
503
+ * used to live in `localStorage`. Never read from it -- adopting its
504
+ * `replayId` would recreate the exact shared-identity bug this file now
505
+ * avoids by using `sessionStorage` instead.
506
+ */
507
+ function clearLegacyLocalStorageReplaySession(): void {
508
+ if (legacyLocalStorageReplaySessionCleared) return;
509
+ legacyLocalStorageReplaySessionCleared = true;
510
+ try {
511
+ window.localStorage.removeItem(SESSION_REPLAY_ID_STORAGE_KEY);
512
+ } catch {
513
+ // best-effort only -- a stray legacy record is harmless once ignored
514
+ }
515
+ }
516
+
378
517
  function generateReplayId(): string {
379
518
  try {
380
519
  if (
@@ -390,7 +529,7 @@ function generateReplayId(): string {
390
529
  }
391
530
 
392
531
  function readStoredReplaySession(): StoredReplaySession | null {
393
- const raw = safeStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
532
+ const raw = safeSessionStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
394
533
  if (!raw) return null;
395
534
  try {
396
535
  const parsed = JSON.parse(raw) as StoredReplaySession;
@@ -402,14 +541,47 @@ function readStoredReplaySession(): StoredReplaySession | null {
402
541
  }
403
542
 
404
543
  function writeStoredReplaySession(value: StoredReplaySession): void {
405
- safeStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
544
+ safeSessionStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
545
+ }
546
+
547
+ function removeStoredReplaySession(replayId: string): void {
548
+ if (readStoredReplaySession()?.replayId !== replayId) return;
549
+ safeSessionStorageRemove(SESSION_REPLAY_ID_STORAGE_KEY);
406
550
  }
407
551
 
552
+ /**
553
+ * Per-tab replay identity is deliberate -- do not "fix" this by reading or
554
+ * writing the session record through `localStorage` again.
555
+ *
556
+ * `sessionStorage` is scoped to a single tab (and survives reloads/
557
+ * navigations within that tab, which is exactly the lifetime a recording
558
+ * needs). `localStorage` is shared by every open tab of the origin. If this
559
+ * record lived there, two tabs open to the same app would read/write the
560
+ * *same* `replayId` and the *same* sequence counter, so rrweb in each tab
561
+ * would record independently but upload chunks under one shared identity.
562
+ * The two interleaved DOM mutation streams get merged into a single
563
+ * recording server-side: mutations reference the other tab's node ids
564
+ * (broken CSS), the viewport/meta events reflect whichever tab resized last
565
+ * (wrong or ultra-wide viewport), and lost mousemove batches from the
566
+ * "other" tab's chunks read as a frozen cursor or a fake inactivity gap. A
567
+ * chunk-sequence collision with a different checksum gets rejected
568
+ * server-side (409) rather than merged, so one tab's batches are silently
569
+ * dropped -- there is no way to reconstruct or repair this at playback time.
570
+ * Keep this per-tab. A tab *duplicated* mid-session still shares a
571
+ * `sessionStorage` snapshot, which is what the `BroadcastChannel` claim
572
+ * check in `startSessionReplayRecorder` guards against.
573
+ */
408
574
  function getOrCreateReplaySession(sessionId: string): {
409
575
  replayId: string;
410
576
  startedAtMs: number;
411
577
  sequence: number;
578
+ /** True when resuming a session found in this tab's `sessionStorage`
579
+ * (e.g. a reload), as opposed to minting a brand-new id. Only the resumed
580
+ * case needs the duplicated-tab claim check -- a fresh id can never
581
+ * collide with anything already recording. */
582
+ resumed: boolean;
412
583
  } {
584
+ clearLegacyLocalStorageReplaySession();
413
585
  const parsed = readStoredReplaySession();
414
586
  if (parsed?.sessionId === sessionId && parsed.replayId) {
415
587
  const startedAtMs =
@@ -424,12 +596,127 @@ function getOrCreateReplaySession(sessionId: string): {
424
596
  parsed.sequence >= 0
425
597
  ? Math.floor(parsed.sequence)
426
598
  : 0;
427
- return { replayId: parsed.replayId, startedAtMs, sequence };
599
+ return { replayId: parsed.replayId, startedAtMs, sequence, resumed: true };
428
600
  }
429
601
  const replayId = generateReplayId();
430
602
  const startedAtMs = Date.now();
431
603
  writeStoredReplaySession({ sessionId, replayId, startedAtMs, sequence: 0 });
432
- return { replayId, startedAtMs, sequence: 0 };
604
+ return { replayId, startedAtMs, sequence: 0, resumed: false };
605
+ }
606
+
607
+ /**
608
+ * Open the cross-tab claim channel used to detect a *duplicated* tab (a
609
+ * browser "duplicate tab" or same-origin `window.open` copies
610
+ * `sessionStorage`, so two tabs can legitimately start with the same
611
+ * resumed `replayId`). Returns `null` when `BroadcastChannel` is
612
+ * unavailable -- callers must treat that as "skip the guard", never as an
613
+ * error.
614
+ */
615
+ function openReplayBroadcastChannel(): BroadcastChannel | null {
616
+ if (typeof BroadcastChannel === "undefined") return null;
617
+ try {
618
+ return new BroadcastChannel(SESSION_REPLAY_BROADCAST_CHANNEL_NAME);
619
+ } catch {
620
+ return null;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Wires up the duplicated-tab claim channel for one recorder lifetime.
626
+ * `respond` keeps listening for the whole life of the channel (any tab may
627
+ * later claim the `replayId` this tab is actively recording); `probeClaim`
628
+ * is used once, only when resuming a stored session, to ask "is anyone else
629
+ * already recording this id?" and wait up to
630
+ * `SESSION_REPLAY_CLAIM_TIMEOUT_MS` for a reply.
631
+ */
632
+ function createReplayClaimChannel(
633
+ state: SessionReplayState,
634
+ instanceNonce: string,
635
+ ): {
636
+ channel: BroadcastChannel | null;
637
+ probeClaim: (replayId: string) => Promise<boolean>;
638
+ } {
639
+ const channel = openReplayBroadcastChannel();
640
+ if (!channel) {
641
+ return { channel: null, probeClaim: async () => false };
642
+ }
643
+ let pending: {
644
+ replayId: string;
645
+ resolve: (taken: boolean) => void;
646
+ timer: number;
647
+ } | null = null;
648
+ channel.onmessage = (event: MessageEvent) => {
649
+ const data = event?.data as ReplayBroadcastMessage | undefined | null;
650
+ if (!data || typeof data !== "object") return;
651
+ if (data.type === "an-replay-claim") {
652
+ if (data.instanceNonce === instanceNonce) return;
653
+ const ownsReplayId = state.active && state.replayId === data.replayId;
654
+ const winsSimultaneousClaim =
655
+ pending?.replayId === data.replayId &&
656
+ instanceNonce.localeCompare(data.instanceNonce) < 0;
657
+ if (!ownsReplayId && !winsSimultaneousClaim) {
658
+ // If both duplicated tabs start simultaneously, deterministically
659
+ // yield to the lower nonce rather than letting both probes time out
660
+ // and record under the copied replay id.
661
+ if (
662
+ pending?.replayId === data.replayId &&
663
+ data.instanceNonce.localeCompare(instanceNonce) < 0
664
+ ) {
665
+ window.clearTimeout(pending.timer);
666
+ const resolve = pending.resolve;
667
+ pending = null;
668
+ resolve(true);
669
+ }
670
+ return;
671
+ }
672
+ try {
673
+ const reply: ReplayClaimTakenMessage = {
674
+ type: "an-replay-claim-taken",
675
+ replayId: data.replayId,
676
+ claimantNonce: data.instanceNonce,
677
+ };
678
+ channel.postMessage(reply);
679
+ } catch {
680
+ // best-effort -- a lost reply just means the duplicate tab resumes
681
+ // recording under the shared id; later 409s still protect the
682
+ // stream from getting corrupted merges.
683
+ }
684
+ return;
685
+ }
686
+ if (data.type === "an-replay-claim-taken") {
687
+ if (
688
+ pending &&
689
+ data.replayId === pending.replayId &&
690
+ (!data.claimantNonce || data.claimantNonce === instanceNonce)
691
+ ) {
692
+ window.clearTimeout(pending.timer);
693
+ const resolve = pending.resolve;
694
+ pending = null;
695
+ resolve(true);
696
+ }
697
+ }
698
+ };
699
+ const probeClaim = (replayId: string): Promise<boolean> =>
700
+ new Promise((resolve) => {
701
+ const timer = window.setTimeout(() => {
702
+ pending = null;
703
+ resolve(false);
704
+ }, SESSION_REPLAY_CLAIM_TIMEOUT_MS);
705
+ pending = { replayId, resolve, timer };
706
+ try {
707
+ const claim: ReplayClaimMessage = {
708
+ type: "an-replay-claim",
709
+ replayId,
710
+ instanceNonce,
711
+ };
712
+ channel.postMessage(claim);
713
+ } catch {
714
+ window.clearTimeout(timer);
715
+ pending = null;
716
+ resolve(false);
717
+ }
718
+ });
719
+ return { channel, probeClaim };
433
720
  }
434
721
 
435
722
  function persistReplaySequence(
@@ -640,13 +927,20 @@ function normalizeOptions(
640
927
  checkoutEveryNth: options.checkoutEveryNth,
641
928
  checkoutEveryNms: options.checkoutEveryNms,
642
929
  inlineStylesheet: options.inlineStylesheet ?? true,
643
- blockSelector: options.blockSelector || DEFAULT_BLOCK_SELECTOR,
930
+ blockSelector: mergeReplayBlockSelector(
931
+ options.blockSelector || DEFAULT_BLOCK_SELECTOR,
932
+ ),
644
933
  ignoreSelector: options.ignoreSelector || DEFAULT_IGNORE_SELECTOR,
645
934
  maskTextClass: options.maskTextClass || DEFAULT_MASK_TEXT_CLASS,
646
935
  maskTextSelector: options.maskTextSelector || DEFAULT_MASK_TEXT_SELECTOR,
647
936
  maskAllInputs: options.maskAllInputs ?? true,
648
937
  recordCanvas: options.recordCanvas ?? false,
649
- recordCrossOriginIframes: options.recordCrossOriginIframes ?? false,
938
+ // A top-level app can safely aggregate cooperative child recorders. An
939
+ // app embedded by an unrelated cross-origin host must continue emitting
940
+ // and uploading its own events, so it only forwards to a parent when the
941
+ // caller explicitly opts in.
942
+ recordCrossOriginIframes:
943
+ options.recordCrossOriginIframes ?? window.parent === window,
650
944
  collectFonts: options.collectFonts ?? false,
651
945
  inlineImages: options.inlineImages ?? false,
652
946
  eventSampling: options.eventSampling ?? DEFAULT_EVENT_SAMPLING,
@@ -658,11 +952,18 @@ function normalizeOptions(
658
952
  options.network,
659
953
  DEFAULT_MAX_NETWORK_EVENTS,
660
954
  ),
955
+ onUploadRejected: options.onUploadRejected,
661
956
  extraProperties: options.extraProperties,
662
957
  shouldStart: options.shouldStart,
663
958
  };
664
959
  }
665
960
 
961
+ function mergeReplayBlockSelector(blockSelector: string): string {
962
+ return blockSelector.includes(SESSION_REPLAY_IFRAME_BLOCK_SELECTOR)
963
+ ? blockSelector
964
+ : `${blockSelector}, ${SESSION_REPLAY_IFRAME_BLOCK_SELECTOR}`;
965
+ }
966
+
666
967
  /**
667
968
  * Console/network capture default to ON whenever session replay records;
668
969
  * `false` disables a category, an object form overrides its caps. Network
@@ -732,8 +1033,159 @@ function scrubReplayValue(
732
1033
  * the scrub into the single serialization pass avoids a separate deep-clone of
733
1034
  * every emitted event (FullSnapshots are large DOM trees) on the hot path.
734
1035
  */
735
- function scrubReplayReplacer(key: string, value: unknown): unknown {
736
- return typeof value === "string" ? scrubStringValue(key, value) : value;
1036
+ const REPLAY_RESOURCE_LINK_RELS = new Set([
1037
+ "stylesheet",
1038
+ "icon",
1039
+ "apple-touch-icon",
1040
+ "mask-icon",
1041
+ ]);
1042
+ const REPLAY_RESOURCE_PRELOAD_TYPES = new Set([
1043
+ "style",
1044
+ "font",
1045
+ "image",
1046
+ "audio",
1047
+ "video",
1048
+ "track",
1049
+ ]);
1050
+ const REPLAY_RESOURCE_TAGS = new Set([
1051
+ "img",
1052
+ "source",
1053
+ "video",
1054
+ "audio",
1055
+ "track",
1056
+ "input",
1057
+ "link",
1058
+ ]);
1059
+ const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set<string>();
1060
+ const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
1061
+ const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
1062
+ const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
1063
+ const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
1064
+
1065
+ function replayAttributeString(
1066
+ attributes: Record<string, unknown>,
1067
+ key: string,
1068
+ ): string {
1069
+ return typeof attributes[key] === "string"
1070
+ ? attributes[key].toLowerCase()
1071
+ : "";
1072
+ }
1073
+
1074
+ function updateReplayResourceNode(
1075
+ current: ReplayResourceNode,
1076
+ attributes: Record<string, unknown>,
1077
+ ): ReplayResourceNode {
1078
+ return {
1079
+ tagName: current.tagName,
1080
+ rel: Object.hasOwn(attributes, "rel")
1081
+ ? replayAttributeString(attributes, "rel")
1082
+ : current.rel,
1083
+ as: Object.hasOwn(attributes, "as")
1084
+ ? replayAttributeString(attributes, "as")
1085
+ : current.as,
1086
+ type: Object.hasOwn(attributes, "type")
1087
+ ? replayAttributeString(attributes, "type")
1088
+ : current.type,
1089
+ };
1090
+ }
1091
+
1092
+ function replayPreservedResourceAttributes(
1093
+ node: ReplayResourceNode,
1094
+ ): ReadonlySet<string> {
1095
+ switch (node.tagName) {
1096
+ case "img":
1097
+ case "source":
1098
+ return REPLAY_SRCSET_ATTRIBUTES;
1099
+ case "video":
1100
+ return REPLAY_VIDEO_ATTRIBUTES;
1101
+ case "audio":
1102
+ case "track":
1103
+ return REPLAY_SRC_ATTRIBUTES;
1104
+ case "input":
1105
+ return node.type === "image"
1106
+ ? REPLAY_SRC_ATTRIBUTES
1107
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
1108
+ case "link": {
1109
+ const rels = node.rel.split(/\s+/);
1110
+ const isLoadBearingResource =
1111
+ rels.some((rel) => REPLAY_RESOURCE_LINK_RELS.has(rel)) ||
1112
+ (rels.includes("preload") &&
1113
+ REPLAY_RESOURCE_PRELOAD_TYPES.has(node.as));
1114
+ return isLoadBearingResource
1115
+ ? REPLAY_HREF_ATTRIBUTES
1116
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
1117
+ }
1118
+ default:
1119
+ return NO_REPLAY_RESOURCE_ATTRIBUTES;
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Build a path-aware replay serializer without cloning the rrweb event.
1125
+ *
1126
+ * Privacy still wins for Meta/navigation URLs, executable/embed URLs, anchor
1127
+ * hrefs, and custom console/network diagnostics. The narrow exception is
1128
+ * load-bearing stylesheet, font, image, and media attributes: changing those
1129
+ * signed URLs makes rrweb rebuild a page that never existed. JSON.stringify
1130
+ * calls a replacer for an `attributes` object before its children, so the
1131
+ * WeakMap lets the child callback recognize only that bag.
1132
+ */
1133
+ function createReplayScrubReplacer(
1134
+ resourceNodes: Map<number, ReplayResourceNode>,
1135
+ ): (this: unknown, key: string, value: unknown) => unknown {
1136
+ const preservedAttributes = new WeakMap<object, ReadonlySet<string>>();
1137
+
1138
+ return function replayScrubReplacer(
1139
+ this: unknown,
1140
+ key: string,
1141
+ value: unknown,
1142
+ ): unknown {
1143
+ if (key === "attributes" && value && typeof value === "object") {
1144
+ const attributes = value as Record<string, unknown>;
1145
+ const holder =
1146
+ this && typeof this === "object"
1147
+ ? (this as Record<string, unknown>)
1148
+ : undefined;
1149
+ const tagName =
1150
+ typeof holder?.tagName === "string" ? holder.tagName.toLowerCase() : "";
1151
+ const nodeId =
1152
+ typeof holder?.id === "number" && Number.isFinite(holder.id)
1153
+ ? holder.id
1154
+ : undefined;
1155
+ let resourceNode: ReplayResourceNode | undefined;
1156
+ if (tagName && REPLAY_RESOURCE_TAGS.has(tagName)) {
1157
+ resourceNode = updateReplayResourceNode(
1158
+ { tagName, rel: "", as: "", type: "" },
1159
+ attributes,
1160
+ );
1161
+ } else if (nodeId !== undefined && !tagName) {
1162
+ const current = resourceNodes.get(nodeId);
1163
+ if (current) {
1164
+ resourceNode = updateReplayResourceNode(current, attributes);
1165
+ }
1166
+ }
1167
+ if (nodeId !== undefined && resourceNode) {
1168
+ resourceNodes.set(nodeId, resourceNode);
1169
+ }
1170
+
1171
+ const resourceKeys = resourceNode
1172
+ ? replayPreservedResourceAttributes(resourceNode)
1173
+ : NO_REPLAY_RESOURCE_ATTRIBUTES;
1174
+
1175
+ if (resourceKeys.size > 0) preservedAttributes.set(value, resourceKeys);
1176
+ return value;
1177
+ }
1178
+
1179
+ if (
1180
+ typeof value === "string" &&
1181
+ this &&
1182
+ typeof this === "object" &&
1183
+ preservedAttributes.get(this)?.has(key.toLowerCase())
1184
+ ) {
1185
+ return value;
1186
+ }
1187
+ return typeof value === "string" ? scrubStringValue(key, value) : value;
1188
+ };
737
1189
  }
738
1190
 
739
1191
  /**
@@ -741,9 +1193,13 @@ function scrubReplayReplacer(key: string, value: unknown): unknown {
741
1193
  * directly on the queue and reused verbatim at flush, so each event is
742
1194
  * stringified exactly once (was: deep-clone + size-stringify + flush-stringify).
743
1195
  */
744
- function serializeReplayEvent(event: ReplayEvent): string {
1196
+ function serializeReplayEvent(
1197
+ event: ReplayEvent,
1198
+ resourceNodes: Map<number, ReplayResourceNode>,
1199
+ ): string {
745
1200
  try {
746
- return JSON.stringify(event, scrubReplayReplacer);
1201
+ if (event.type === 2) resourceNodes.clear();
1202
+ return JSON.stringify(event, createReplayScrubReplacer(resourceNodes));
747
1203
  } catch {
748
1204
  return "";
749
1205
  }
@@ -766,9 +1222,14 @@ function enqueueReplayEvent(
766
1222
  event: ReplayEvent,
767
1223
  ): void {
768
1224
  if (!state.options) return;
769
- const serialized = serializeReplayEvent(event);
1225
+ const eventType = typeof event.type === "number" ? event.type : null;
1226
+ if (state.awaitingFullSnapshot) {
1227
+ if (eventType !== RRWEB_FULL_SNAPSHOT_EVENT_TYPE) return;
1228
+ state.awaitingFullSnapshot = false;
1229
+ }
1230
+ const serialized = serializeReplayEvent(event, state.resourceNodes);
770
1231
  if (!serialized) return;
771
- const estimatedBytes = serialized.length;
1232
+ const estimatedBytes = replaySerializedBytes(serialized);
772
1233
  if (
773
1234
  state.queue.length > 0 &&
774
1235
  state.queuedBytes + estimatedBytes > state.options.maxBatchBytes
@@ -777,8 +1238,9 @@ function enqueueReplayEvent(
777
1238
  }
778
1239
  state.queue.push({
779
1240
  json: serialized,
1241
+ byteLength: estimatedBytes,
780
1242
  timestampMs: replayEventTimestampMs(event),
781
- type: typeof event.type === "number" ? event.type : null,
1243
+ type: eventType,
782
1244
  });
783
1245
  state.queuedBytes += estimatedBytes;
784
1246
  flushQueuedReplayIfNeeded(state);
@@ -977,6 +1439,27 @@ function canUseReplayKeepalive(body: BodyInit): boolean {
977
1439
  return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
978
1440
  }
979
1441
 
1442
+ /** Thrown by `sendReplayUpload` on a non-ok HTTP response, carrying the
1443
+ * status so `flushSessionReplay` can tell a permanent client rejection
1444
+ * (e.g. a 409 checksum conflict, which can never succeed on retry) apart
1445
+ * from a transient failure worth retrying. */
1446
+ class ReplayUploadHttpError extends Error {
1447
+ readonly status: number;
1448
+ constructor(status: number) {
1449
+ super(`Session replay upload failed with HTTP ${status}`);
1450
+ this.name = "ReplayUploadHttpError";
1451
+ this.status = status;
1452
+ }
1453
+ }
1454
+
1455
+ /** 4xx statuses where retrying the exact same batch can never succeed --
1456
+ * e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
1457
+ * reject again. 408 (timeout) and 429 (rate limit) are excluded because a
1458
+ * later retry can plausibly succeed. */
1459
+ function isDefinitiveReplayUploadClientError(status: number): boolean {
1460
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
1461
+ }
1462
+
980
1463
  async function sendReplayUpload(
981
1464
  options: NormalizedSessionReplayOptions,
982
1465
  body: string,
@@ -992,9 +1475,7 @@ async function sendReplayUpload(
992
1475
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
993
1476
  });
994
1477
  if (!response.ok) {
995
- throw new Error(
996
- `Session replay upload failed with HTTP ${response.status}`,
997
- );
1478
+ throw new ReplayUploadHttpError(response.status);
998
1479
  }
999
1480
  return;
1000
1481
  }
@@ -1012,9 +1493,7 @@ async function sendReplayUpload(
1012
1493
  },
1013
1494
  });
1014
1495
  if (!response.ok) {
1015
- throw new Error(
1016
- `Session replay upload failed with HTTP ${response.status}`,
1017
- );
1496
+ throw new ReplayUploadHttpError(response.status);
1018
1497
  }
1019
1498
  }
1020
1499
 
@@ -1029,6 +1508,25 @@ function isFinalFlushReason(reason: string): boolean {
1029
1508
  ].includes(reason);
1030
1509
  }
1031
1510
 
1511
+ function flushReasonPriority(reason: string): number {
1512
+ // Unload reasons must retain their keepalive sequence reservation even when
1513
+ // another final request (for example, an explicit manual stop) is coalesced.
1514
+ if (reason === "pagehide" || reason === "beforeunload") return 3;
1515
+ if (isFinalFlushReason(reason)) return 2;
1516
+ if (reason === "visibility-hidden") return 1;
1517
+ return 0;
1518
+ }
1519
+
1520
+ function mergePendingFlushReason(
1521
+ current: string | null,
1522
+ requested: string,
1523
+ ): string {
1524
+ if (!current) return requested;
1525
+ return flushReasonPriority(requested) > flushReasonPriority(current)
1526
+ ? requested
1527
+ : current;
1528
+ }
1529
+
1032
1530
  function shouldReserveSequenceBeforeKeepalive(reason: string): boolean {
1033
1531
  return (
1034
1532
  reason === "pagehide" ||
@@ -1068,7 +1566,99 @@ function flushQueuedReplayIfNeeded(state: SessionReplayState): void {
1068
1566
  }
1069
1567
 
1070
1568
  function queuedReplayBytes(events: QueuedReplayEvent[]): number {
1071
- return events.reduce((total, event) => total + event.json.length, 0);
1569
+ return events.reduce(
1570
+ (total, event) => total + queuedReplayEventBytes(event),
1571
+ 0,
1572
+ );
1573
+ }
1574
+
1575
+ function replaySerializedBytes(value: string): number {
1576
+ if (REPLAY_TEXT_ENCODER) return REPLAY_TEXT_ENCODER.encode(value).byteLength;
1577
+ if (typeof Blob !== "undefined") return new Blob([value]).size;
1578
+ return value.length;
1579
+ }
1580
+
1581
+ function queuedReplayEventBytes(event: QueuedReplayEvent): number {
1582
+ return Number.isFinite(event.byteLength)
1583
+ ? event.byteLength
1584
+ : replaySerializedBytes(event.json);
1585
+ }
1586
+
1587
+ /**
1588
+ * Remove one bounded FIFO prefix from the live queue.
1589
+ *
1590
+ * Threshold-triggered flushes may arrive while another upload is active. The
1591
+ * old `queue.splice(0)` drained that entire accumulated backlog on the next
1592
+ * flush, bypassing both byte and event caps. Keep FullSnapshots isolated and
1593
+ * always take at least one event so an individually large snapshot can still
1594
+ * make progress.
1595
+ */
1596
+ function takeQueuedReplayBatch(state: SessionReplayState): QueuedReplayEvent[] {
1597
+ const options = state.options;
1598
+ if (!options || state.queue.length === 0) return [];
1599
+
1600
+ let count = 0;
1601
+ let bytes = 0;
1602
+ for (const event of state.queue) {
1603
+ if (count > 0 && event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE) break;
1604
+ if (
1605
+ count > 0 &&
1606
+ (count >= options.maxEventsPerBatch ||
1607
+ bytes + queuedReplayEventBytes(event) > options.maxBatchBytes)
1608
+ ) {
1609
+ break;
1610
+ }
1611
+ count += 1;
1612
+ bytes += queuedReplayEventBytes(event);
1613
+ if (event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE) break;
1614
+ }
1615
+
1616
+ const events = state.queue.splice(0, Math.max(1, count));
1617
+ state.queuedBytes = queuedReplayBytes(state.queue);
1618
+ return events;
1619
+ }
1620
+
1621
+ function splitReplayBatch(
1622
+ events: QueuedReplayEvent[],
1623
+ ): [QueuedReplayEvent[], QueuedReplayEvent[]] | null {
1624
+ if (events.length < 2) return null;
1625
+ const targetBytes = queuedReplayBytes(events) / 2;
1626
+ let splitAt = 1;
1627
+ let bytes = events[0] ? queuedReplayEventBytes(events[0]) : 0;
1628
+ while (splitAt < events.length - 1 && bytes < targetBytes) {
1629
+ const event = events[splitAt];
1630
+ if (event) bytes += queuedReplayEventBytes(event);
1631
+ splitAt += 1;
1632
+ }
1633
+ return [events.slice(0, splitAt), events.slice(splitAt)];
1634
+ }
1635
+
1636
+ function replayBatchNeedsDomReset(events: QueuedReplayEvent[]): boolean {
1637
+ return events.some((event) => {
1638
+ if (event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE) return true;
1639
+ if (event.type !== 3) return false;
1640
+ try {
1641
+ const parsed = JSON.parse(event.json) as { data?: { source?: unknown } };
1642
+ // rrweb IncrementalSource.Mutation is zero. Dropping one may leave later
1643
+ // node references dangling until another FullSnapshot resets the mirror.
1644
+ return parsed.data?.source === 0;
1645
+ } catch {
1646
+ return true;
1647
+ }
1648
+ });
1649
+ }
1650
+
1651
+ function quarantinePendingReplayUntilFullSnapshot(
1652
+ state: SessionReplayState,
1653
+ ): void {
1654
+ const pending = [...state.retryBatches.flat(), ...state.queue];
1655
+ const resetAt = pending.findIndex(
1656
+ (event) => event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE,
1657
+ );
1658
+ state.retryBatches = [];
1659
+ state.queue = resetAt >= 0 ? pending.slice(resetAt) : [];
1660
+ state.queuedBytes = queuedReplayBytes(state.queue);
1661
+ state.awaitingFullSnapshot = resetAt < 0;
1072
1662
  }
1073
1663
 
1074
1664
  function restoreReplayEvents(
@@ -1109,9 +1699,18 @@ function rollbackReplaySequenceReservation(
1109
1699
 
1110
1700
  export async function flushSessionReplay(reason = "manual"): Promise<void> {
1111
1701
  const state = getState();
1112
- if (!state.options || !hasPendingReplayBatch(state) || state.flushing) return;
1113
- const events = state.retryBatches.shift() ?? state.queue.splice(0);
1114
- state.queuedBytes = queuedReplayBytes(state.queue);
1702
+ if (!state.options) return;
1703
+ if (state.flushing) {
1704
+ state.pendingFlushReason = mergePendingFlushReason(
1705
+ state.pendingFlushReason,
1706
+ reason,
1707
+ );
1708
+ return new Promise<void>((resolve) => {
1709
+ state.pendingFlushWaiters.push(resolve);
1710
+ });
1711
+ }
1712
+ if (!hasPendingReplayBatch(state)) return;
1713
+ const events = state.retryBatches.shift() ?? takeQueuedReplayBatch(state);
1115
1714
  const payload = buildReplayBody(state, reason, events);
1116
1715
  if (!payload || !state.options) {
1117
1716
  restoreReplayEvents(state, events);
@@ -1120,6 +1719,9 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1120
1719
  state.flushing = true;
1121
1720
  let uploaded = false;
1122
1721
  let reservedSequence = false;
1722
+ let splitRejectedBatch = false;
1723
+ let droppedOversizedBatch = false;
1724
+ let definitiveClientErrorStatus: number | null = null;
1123
1725
  try {
1124
1726
  await sendReplayUpload(state.options, payload.body, {
1125
1727
  beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
@@ -1130,25 +1732,207 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1130
1732
  : undefined,
1131
1733
  });
1132
1734
  if (!reservedSequence) advanceReplaySequence(state, payload);
1735
+ state.automaticConflictRestartAttempted = false;
1133
1736
  uploaded = true;
1134
1737
  } catch (error) {
1135
1738
  if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
1136
- restoreReplayEvents(state, events);
1739
+ // A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
1740
+ // never succeed by retrying the exact same batch -- requeuing it would
1741
+ // just spin forever, blocking every later batch behind it (flushes are
1742
+ // FIFO via `retryBatches`). Drop it and move on instead.
1743
+ const rejectedStatus =
1744
+ error instanceof ReplayUploadHttpError ? error.status : null;
1745
+ const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
1746
+ const isUnsplittableOversizedBatch =
1747
+ rejectedStatus === 413 && splitBatch === null;
1748
+ const isDefinitiveClientError =
1749
+ error instanceof ReplayUploadHttpError &&
1750
+ isDefinitiveReplayUploadClientError(error.status) &&
1751
+ !splitBatch &&
1752
+ !isUnsplittableOversizedBatch;
1753
+ if (splitBatch) {
1754
+ // A server or platform can enforce a stricter decompressed-body limit
1755
+ // than the recorder's configured queue cap. Bisect in FIFO order and
1756
+ // retry both halves at the same sequence; only successful halves advance
1757
+ // it, so no event is duplicated or skipped.
1758
+ state.retryBatches.unshift(...splitBatch);
1759
+ splitRejectedBatch = true;
1760
+ } else if (isUnsplittableOversizedBatch) {
1761
+ // This one event cannot be made any smaller. Drop only the rejected
1762
+ // singleton and keep later retry halves/live batches in FIFO order. If
1763
+ // it carried DOM structure, quarantine dependent mutations until a new
1764
+ // FullSnapshot can safely re-anchor rrweb's node mirror.
1765
+ droppedOversizedBatch = true;
1766
+ if (replayBatchNeedsDomReset(events)) {
1767
+ quarantinePendingReplayUntilFullSnapshot(state);
1768
+ }
1769
+ } else if (isDefinitiveClientError) {
1770
+ // Continuing after a checksum/sequence conflict would reuse the same
1771
+ // rejected sequence forever. More importantly, advancing past it would
1772
+ // append mutations to a replay whose DOM stream may belong to another
1773
+ // tab. End this recorder and clear its persisted identity so the next
1774
+ // start creates a clean replay instead of producing corrupt playback.
1775
+ state.queue = [];
1776
+ state.queuedBytes = 0;
1777
+ state.retryBatches = [];
1778
+ removeStoredReplaySession(payload.replayId);
1779
+ definitiveClientErrorStatus = error.status;
1780
+ } else {
1781
+ restoreReplayEvents(state, events);
1782
+ }
1137
1783
  // Guard the recorder's own warning so console capture never records it
1138
1784
  // (a captured warning would enqueue an event and retrigger a flush).
1139
1785
  const previousInternal = replayCaptureInternal;
1140
1786
  replayCaptureInternal = true;
1141
1787
  try {
1142
- console.warn("[session-replay] upload failed", error);
1788
+ if (splitRejectedBatch) {
1789
+ console.warn(
1790
+ "[session-replay] splitting oversized upload (HTTP 413)",
1791
+ error,
1792
+ );
1793
+ } else if (droppedOversizedBatch) {
1794
+ console.warn(
1795
+ "[session-replay] dropping oversized replay event (HTTP 413)",
1796
+ error,
1797
+ );
1798
+ } else if (isDefinitiveClientError) {
1799
+ console.warn(
1800
+ `[session-replay] dropping upload (HTTP ${(error as ReplayUploadHttpError).status})`,
1801
+ error,
1802
+ );
1803
+ } else {
1804
+ console.warn("[session-replay] upload failed", error);
1805
+ }
1143
1806
  } finally {
1144
1807
  replayCaptureInternal = previousInternal;
1145
1808
  }
1146
1809
  } finally {
1147
1810
  state.flushing = false;
1148
1811
  }
1149
- if (uploaded && hasPendingReplayBatch(state)) {
1150
- flushQueuedReplayIfNeeded(state);
1812
+ const coalescedReason = state.pendingFlushReason;
1813
+ const coalescedWaiters = coalescedReason
1814
+ ? state.pendingFlushWaiters.splice(0)
1815
+ : [];
1816
+ if (coalescedReason) state.pendingFlushReason = null;
1817
+
1818
+ if (coalescedReason) {
1819
+ // A stop/unload request that arrived during this upload owns the tail.
1820
+ // Its higher-priority reason must replace threshold/internal reasons so a
1821
+ // small final batch is not stranded or mislabeled as active.
1822
+ await flushSessionReplay(coalescedReason);
1823
+ } else if (splitRejectedBatch || droppedOversizedBatch) {
1824
+ // Preserve final-status and unload semantics through every half. In
1825
+ // particular, pagehide/beforeunload retries must still reserve their
1826
+ // sequence before a keepalive fetch, and completed flushes must not be
1827
+ // rewritten to an internal recovery reason/status.
1828
+ await flushSessionReplay(reason);
1829
+ } else if (uploaded && hasPendingReplayBatch(state)) {
1830
+ const mustContinue =
1831
+ state.retryBatches.length > 0 ||
1832
+ isFinalFlushReason(reason) ||
1833
+ shouldFlushQueuedReplay(state);
1834
+ if (mustContinue) {
1835
+ if (isFinalFlushReason(reason)) {
1836
+ await flushSessionReplay(reason);
1837
+ } else void flushSessionReplay(reason);
1838
+ }
1839
+ }
1840
+ if (
1841
+ definitiveClientErrorStatus !== null &&
1842
+ state.replayId === payload.replayId
1843
+ ) {
1844
+ const rejectedOptions = state.options;
1845
+ const shouldRestartAfterConflict =
1846
+ definitiveClientErrorStatus === 409 &&
1847
+ state.active &&
1848
+ !isFinalFlushReason(reason) &&
1849
+ !state.automaticConflictRestartAttempted;
1850
+ if (shouldRestartAfterConflict) {
1851
+ state.automaticConflictRestartAttempted = true;
1852
+ }
1853
+
1854
+ await stopSessionReplay("upload-rejected");
1855
+
1856
+ // A 409 means this replay identity can no longer append safely (usually a
1857
+ // duplicated tab that inherited sessionStorage, or an old shared identity
1858
+ // still open during rollout). Do not leave a long-lived SPA tab silently
1859
+ // unrecorded until its next page load: restart rrweb under a fresh per-tab
1860
+ // id so it emits a new Meta + FullSnapshot stream. Limit this to one retry
1861
+ // until an upload succeeds; other definitive 4xx responses usually reflect
1862
+ // configuration/input errors and must not create a restart loop.
1863
+ let restartResult: SessionReplayStartResult | null = null;
1864
+ if (shouldRestartAfterConflict && rejectedOptions) {
1865
+ restartResult = await restartSessionReplayAfterConflict(
1866
+ state,
1867
+ rejectedOptions,
1868
+ payload.sessionId,
1869
+ );
1870
+ }
1871
+
1872
+ // Rare recovery-path telemetry lets Analytics owners quantify conflicts
1873
+ // without recording the rejected replay id, URL, or any captured content.
1874
+ try {
1875
+ rejectedOptions?.onUploadRejected?.({
1876
+ status: definitiveClientErrorStatus,
1877
+ restartAttempted: shouldRestartAfterConflict,
1878
+ restartSucceeded: restartResult?.started === true,
1879
+ ...(restartResult?.reason
1880
+ ? { restartReason: restartResult.reason }
1881
+ : {}),
1882
+ });
1883
+ } catch {
1884
+ // best-effort telemetry must never interfere with recording recovery
1885
+ }
1886
+ }
1887
+ for (const resolve of coalescedWaiters) resolve();
1888
+ }
1889
+
1890
+ /**
1891
+ * Restart a recorder whose replay identity was rejected without routing the
1892
+ * already-normalized options back through the public start API.
1893
+ *
1894
+ * The original recording already passed whole-session sampling. Re-entering
1895
+ * `startSessionReplay` here would ask `getOrCreateAnalyticsSessionId` again;
1896
+ * if the analytics session rotated while the upload was in flight, recovery
1897
+ * could be sampled out and leave a long-lived SPA tab silently unrecorded.
1898
+ * Keeping the original session id and accepted sampling decision also lets us
1899
+ * reuse the exact normalized console/network capture caps instead of relying
1900
+ * on internal option shapes continuing to round-trip through the public API.
1901
+ */
1902
+ async function restartSessionReplayAfterConflict(
1903
+ state: SessionReplayState,
1904
+ options: NormalizedSessionReplayOptions,
1905
+ sessionId: string,
1906
+ ): Promise<SessionReplayStartResult> {
1907
+ if (options.shouldStart && !options.shouldStart()) {
1908
+ return { started: false, reason: "disabled", sessionId, sampled: true };
1909
+ }
1910
+
1911
+ const initialProperties = replayExtraProperties(options);
1912
+ if (options.requireSignedInUser && !replayUserEmail(initialProperties)) {
1913
+ return {
1914
+ started: false,
1915
+ reason: "missing-user-id",
1916
+ sessionId,
1917
+ sampled: true,
1918
+ };
1919
+ }
1920
+ if (!isUrlRecordable(window.location.href, options)) {
1921
+ return {
1922
+ started: false,
1923
+ reason: "url-blocked",
1924
+ sessionId,
1925
+ sampled: true,
1926
+ };
1151
1927
  }
1928
+
1929
+ return startSessionReplayRecorder(
1930
+ state,
1931
+ options,
1932
+ sessionId,
1933
+ true,
1934
+ initialProperties,
1935
+ );
1152
1936
  }
1153
1937
 
1154
1938
  function installUrlMonitor(state: SessionReplayState): void {
@@ -1199,6 +1983,101 @@ function installLifecycleListeners(state: SessionReplayState): void {
1199
1983
  };
1200
1984
  }
1201
1985
 
1986
+ function markedSessionReplayIframes(): HTMLIFrameElement[] {
1987
+ if (typeof document.querySelectorAll !== "function") return [];
1988
+ return Array.from(
1989
+ document.querySelectorAll<HTMLIFrameElement>(
1990
+ `iframe[${SESSION_REPLAY_IFRAME_ATTRIBUTE}]`,
1991
+ ),
1992
+ );
1993
+ }
1994
+
1995
+ function markedSessionReplayIframeForSource(
1996
+ source: MessageEventSource | null,
1997
+ ): HTMLIFrameElement | null {
1998
+ if (!source) return null;
1999
+ return (
2000
+ markedSessionReplayIframes().find(
2001
+ (iframe) => iframe.contentWindow === source,
2002
+ ) ?? null
2003
+ );
2004
+ }
2005
+
2006
+ function sessionReplayIframePrivacyOptions(
2007
+ options: NormalizedSessionReplayOptions,
2008
+ ): SessionReplayIframePrivacyOptions {
2009
+ return {
2010
+ blockSelector: options.blockSelector,
2011
+ ignoreSelector: options.ignoreSelector,
2012
+ maskTextClass: options.maskTextClass,
2013
+ maskTextSelector: options.maskTextSelector,
2014
+ maskAllInputs: options.maskAllInputs,
2015
+ maskInputOptions: DEFAULT_MASK_INPUT_OPTIONS,
2016
+ recordCanvas: options.recordCanvas,
2017
+ collectFonts: options.collectFonts,
2018
+ inlineImages: options.inlineImages,
2019
+ sampling: options.eventSampling,
2020
+ };
2021
+ }
2022
+
2023
+ function postSessionReplayIframeMessage(
2024
+ iframe: HTMLIFrameElement,
2025
+ message: SessionReplayIframeStartMessage | SessionReplayIframeStopMessage,
2026
+ ): void {
2027
+ try {
2028
+ iframe.contentWindow?.postMessage(message, "*");
2029
+ } catch {
2030
+ // The frame may have navigated or detached between discovery and send.
2031
+ }
2032
+ }
2033
+
2034
+ /**
2035
+ * Cooperative opaque/cross-origin iframe recording.
2036
+ *
2037
+ * rrweb cannot inspect these documents from the host. Framework-owned child
2038
+ * frames carry a marker and inject a tiny recorder that probes its direct
2039
+ * parent. Only an active host recorder responds, and only when the probe's
2040
+ * source is the contentWindow of a currently marked direct iframe.
2041
+ */
2042
+ function installSessionReplayIframeBridge(
2043
+ state: SessionReplayState,
2044
+ options: NormalizedSessionReplayOptions,
2045
+ ): void {
2046
+ if (!options.recordCrossOriginIframes || state.restoreIframeBridge) return;
2047
+
2048
+ const startMessage: SessionReplayIframeStartMessage = {
2049
+ type: SESSION_REPLAY_IFRAME_START,
2050
+ options: sessionReplayIframePrivacyOptions(options),
2051
+ };
2052
+ const stopMessage: SessionReplayIframeStopMessage = {
2053
+ type: SESSION_REPLAY_IFRAME_STOP,
2054
+ };
2055
+ const sendStart = (iframe: HTMLIFrameElement) =>
2056
+ postSessionReplayIframeMessage(iframe, startMessage);
2057
+ const onMessage = (event: MessageEvent) => {
2058
+ if (!state.active) return;
2059
+ if (
2060
+ !event.data ||
2061
+ typeof event.data !== "object" ||
2062
+ event.data.type !== SESSION_REPLAY_IFRAME_PROBE
2063
+ ) {
2064
+ return;
2065
+ }
2066
+ const iframe = markedSessionReplayIframeForSource(event.source);
2067
+ if (iframe) sendStart(iframe);
2068
+ };
2069
+
2070
+ window.addEventListener("message", onMessage);
2071
+ for (const iframe of markedSessionReplayIframes()) sendStart(iframe);
2072
+ state.restoreIframeBridge = () => {
2073
+ window.removeEventListener("message", onMessage);
2074
+ for (const iframe of markedSessionReplayIframes()) {
2075
+ postSessionReplayIframeMessage(iframe, stopMessage);
2076
+ }
2077
+ state.restoreIframeBridge = null;
2078
+ };
2079
+ }
2080
+
1202
2081
  function truncateCaptureText(value: string, maxLength: number): string {
1203
2082
  return value.length > maxLength ? value.slice(0, maxLength) : value;
1204
2083
  }
@@ -2043,7 +2922,34 @@ async function startSessionReplayRecorder(
2043
2922
  return { started: false, reason: "disabled", sessionId, sampled };
2044
2923
  }
2045
2924
 
2046
- const replaySession = getOrCreateReplaySession(sessionId);
2925
+ let replaySession = getOrCreateReplaySession(sessionId);
2926
+ const instanceNonce = generateReplayId();
2927
+ const { channel: replayChannel, probeClaim } = createReplayClaimChannel(
2928
+ state,
2929
+ instanceNonce,
2930
+ );
2931
+ // Only a *resumed* id needs the duplicated-tab check -- a freshly minted
2932
+ // id can never collide with a recorder that's already running, so this
2933
+ // never adds startup latency for the common "new tab" case.
2934
+ if (replaySession.resumed && replayChannel) {
2935
+ const taken = await probeClaim(replaySession.replayId);
2936
+ if (taken) {
2937
+ const freshReplayId = generateReplayId();
2938
+ const freshStartedAtMs = Date.now();
2939
+ writeStoredReplaySession({
2940
+ sessionId,
2941
+ replayId: freshReplayId,
2942
+ startedAtMs: freshStartedAtMs,
2943
+ sequence: 0,
2944
+ });
2945
+ replaySession = {
2946
+ replayId: freshReplayId,
2947
+ startedAtMs: freshStartedAtMs,
2948
+ sequence: 0,
2949
+ resumed: false,
2950
+ };
2951
+ }
2952
+ }
2047
2953
  state.options = normalized;
2048
2954
  state.replayId = replaySession.replayId;
2049
2955
  state.startedAtMs = replaySession.startedAtMs;
@@ -2051,7 +2957,12 @@ async function startSessionReplayRecorder(
2051
2957
  state.queue = [];
2052
2958
  state.queuedBytes = 0;
2053
2959
  state.retryBatches = [];
2960
+ state.pendingFlushReason = null;
2961
+ for (const resolve of state.pendingFlushWaiters.splice(0)) resolve();
2962
+ state.awaitingFullSnapshot = false;
2963
+ state.resourceNodes.clear();
2054
2964
  state.stopRecorder = null;
2965
+ state.broadcastChannel = replayChannel;
2055
2966
  state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
2056
2967
  ? { ...initialProperties }
2057
2968
  : null;
@@ -2073,22 +2984,7 @@ async function startSessionReplayRecorder(
2073
2984
  recordCrossOriginIframes: normalized.recordCrossOriginIframes,
2074
2985
  collectFonts: normalized.collectFonts,
2075
2986
  inlineImages: normalized.inlineImages,
2076
- maskInputOptions: {
2077
- color: true,
2078
- date: true,
2079
- "datetime-local": true,
2080
- email: true,
2081
- month: true,
2082
- number: true,
2083
- password: true,
2084
- range: true,
2085
- search: true,
2086
- tel: true,
2087
- text: true,
2088
- time: true,
2089
- url: true,
2090
- week: true,
2091
- },
2987
+ maskInputOptions: DEFAULT_MASK_INPUT_OPTIONS,
2092
2988
  });
2093
2989
  if (typeof stopRecorder !== "function") {
2094
2990
  state.active = false;
@@ -2096,6 +2992,12 @@ async function startSessionReplayRecorder(
2096
2992
  state.replayId = null;
2097
2993
  state.startedAtMs = null;
2098
2994
  state.lastAuthenticatedProperties = null;
2995
+ try {
2996
+ state.broadcastChannel?.close();
2997
+ } catch {
2998
+ // best-effort cleanup
2999
+ }
3000
+ state.broadcastChannel = null;
2099
3001
  return { started: false, reason: "record-failed", sessionId, sampled };
2100
3002
  }
2101
3003
  state.stopRecorder = stopRecorder;
@@ -2109,6 +3011,7 @@ async function startSessionReplayRecorder(
2109
3011
  );
2110
3012
  installUrlMonitor(state);
2111
3013
  installLifecycleListeners(state);
3014
+ installSessionReplayIframeBridge(state, normalized);
2112
3015
  state.addCustomEvent =
2113
3016
  typeof rrweb.record.addCustomEvent === "function"
2114
3017
  ? rrweb.record.addCustomEvent
@@ -2127,12 +3030,19 @@ async function startSessionReplayRecorder(
2127
3030
  // best-effort interceptor teardown
2128
3031
  }
2129
3032
  state.restoreCaptures = null;
3033
+ state.restoreIframeBridge?.();
2130
3034
  state.addCustomEvent = null;
2131
3035
  state.active = false;
2132
3036
  state.options = null;
2133
3037
  state.replayId = null;
2134
3038
  state.startedAtMs = null;
2135
3039
  state.lastAuthenticatedProperties = null;
3040
+ try {
3041
+ state.broadcastChannel?.close();
3042
+ } catch {
3043
+ // best-effort cleanup
3044
+ }
3045
+ state.broadcastChannel = null;
2136
3046
  return { started: false, reason: "record-failed", sessionId, sampled };
2137
3047
  }
2138
3048
  }
@@ -2149,6 +3059,7 @@ export async function stopSessionReplay(reason = "manual"): Promise<void> {
2149
3059
  // best-effort interceptor teardown
2150
3060
  }
2151
3061
  state.restoreCaptures = null;
3062
+ state.restoreIframeBridge?.();
2152
3063
  state.active = false;
2153
3064
  state.addCustomEvent = null;
2154
3065
  try {
@@ -2167,6 +3078,12 @@ export async function stopSessionReplay(reason = "manual"): Promise<void> {
2167
3078
  }
2168
3079
  state.restoreUrlMonitor?.();
2169
3080
  state.removeLifecycleListeners?.();
3081
+ try {
3082
+ state.broadcastChannel?.close();
3083
+ } catch {
3084
+ // best-effort cleanup
3085
+ }
3086
+ state.broadcastChannel = null;
2170
3087
  await flushSessionReplay(reason);
2171
3088
  }
2172
3089
 
@@ -2182,9 +3099,10 @@ export function isSessionReplayActive(): boolean {
2182
3099
 
2183
3100
  /**
2184
3101
  * 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>`.
3102
+ * persisted for this analytics session in this tab's `sessionStorage`.
3103
+ * First-party error capture uses this to tie each captured exception to the
3104
+ * replay it happened in, so triage can jump straight to
3105
+ * `/sessions/<recordingId>`.
2188
3106
  */
2189
3107
  export function getSessionReplayId(): string | null {
2190
3108
  const state = getState();