@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +29 -0
- package/corpus/core/docs/content/durable-background-runs.mdx +37 -0
- package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/de-DE/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/es-ES/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +4 -2
- package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +2 -0
- package/corpus/core/docs/content/locales/zh-TW/durable-background-runs.mdx +14 -0
- package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +2 -0
- package/corpus/core/docs/content/tracking.mdx +2 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +16 -16
- package/corpus/core/src/agent/engine/ai-sdk-engine.ts +25 -8
- package/corpus/core/src/agent/engine/anthropic-engine.ts +17 -3
- package/corpus/core/src/agent/engine/output-tokens.ts +6 -6
- package/corpus/core/src/agent/production-agent.ts +200 -27
- package/corpus/core/src/agent/run-manager.ts +81 -1
- package/corpus/core/src/agent/run-store.ts +301 -41
- package/corpus/core/src/client/AgentPanel.tsx +6 -2
- package/corpus/core/src/client/AssistantChat.tsx +364 -69
- package/corpus/core/src/client/agent-chat-adapter.ts +169 -1
- package/corpus/core/src/client/analytics.ts +12 -0
- package/corpus/core/src/client/chat/message-components.tsx +3 -0
- package/corpus/core/src/client/chat/tool-call-display.tsx +20 -36
- package/corpus/core/src/client/conversation/use-near-bottom-autoscroll.ts +7 -0
- package/corpus/core/src/client/extensions/AgentNativeExtensionFrame.tsx +2 -0
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +2 -0
- package/corpus/core/src/client/extensions/ExtensionEditor.tsx +9 -2
- package/corpus/core/src/client/extensions/ExtensionViewer.tsx +2 -0
- package/corpus/core/src/client/extensions/InlineExtensionFrame.tsx +2 -0
- package/corpus/core/src/client/extensions/portable-extension.ts +2 -0
- package/corpus/core/src/client/session-replay.ts +971 -53
- package/corpus/core/src/client/settings/DemoModeSection.tsx +3 -3
- package/corpus/core/src/client/sse-event-processor.ts +43 -1
- package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
- package/corpus/core/src/demo/config.ts +6 -6
- package/corpus/core/src/demo/fetch-interceptor.ts +34 -2
- package/corpus/core/src/demo/redact.ts +71 -158
- package/corpus/core/src/extensions/html-shell.ts +3 -0
- package/corpus/core/src/extensions/session-replay-iframe.ts +131 -0
- package/corpus/core/src/scripts/agent-engines/list-agent-engines.ts +1 -20
- package/corpus/core/src/session-replay-iframe-protocol.ts +58 -0
- package/corpus/core/src/shared/reasoning-effort.ts +35 -0
- package/corpus/core/src/styles/agent-conversation.css +26 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +67 -16
- package/corpus/templates/analytics/AGENTS.md +12 -0
- package/corpus/templates/analytics/actions/get-error-issue.ts +1 -0
- package/corpus/templates/analytics/actions/get-session-replay-events.ts +1 -0
- package/corpus/templates/analytics/actions/get-session-replay-summary.ts +1 -0
- package/corpus/templates/analytics/actions/list-error-issues.ts +1 -0
- package/corpus/templates/analytics/actions/list-session-recordings.ts +1 -0
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -1
- package/corpus/templates/analytics/app/global.css +6 -64
- package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -4
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +143 -121
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +34 -12
- package/corpus/templates/analytics/changelog/2026-07-12-analytics-chat-retries-before-showing-a-generic-no-data-mess.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-analytics-sessions-search-no-longer-loses-fast-keystrokes-an.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-chart-selection-respects-chat-state.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-demo-mode-anonymizes-error-reporting-emails.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-fixed-session-replays-that-appeared-ultra-wide-hid-recorded-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-session-replays-load-faster-in-demo-mode-while-visitor-email.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-session-replays-show-iframe-content.md +6 -0
- package/corpus/templates/analytics/netlify.toml +0 -3
- package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +14 -0
- package/corpus/templates/analytics/server/lib/error-capture.ts +59 -2
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +24 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +2 -9
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +39 -6
- package/corpus/templates/assets/app/global.css +1 -1
- package/corpus/templates/brain/app/global.css +2 -2
- package/corpus/templates/brain/changelog/2026-07-12-fix-long-response-progress.md +6 -0
- package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +11 -2
- package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +14 -2
- package/corpus/templates/calendar/changelog/2026-07-12-calendar-responses-can-now-be-saved-with-command-enter-or-ct.md +6 -0
- package/corpus/templates/forms/app/components/CloudUpgrade.tsx +1 -1
- package/corpus/templates/forms/app/components/ThemeToggle.tsx +22 -6
- package/corpus/templates/forms/app/components/builder/FieldPropertiesPanel.tsx +5 -5
- package/corpus/templates/forms/app/components/builder/FieldRenderer.tsx +3 -3
- package/corpus/templates/forms/app/components/layout/Sidebar.tsx +16 -16
- package/corpus/templates/forms/app/global.css +110 -5
- package/corpus/templates/forms/app/pages/AskPage.tsx +3 -3
- package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +134 -52
- package/corpus/templates/forms/app/pages/FormFillPage.tsx +44 -11
- package/corpus/templates/forms/app/pages/FormsListPage.tsx +42 -24
- package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +28 -15
- package/corpus/templates/forms/app/pages/ResponsesPage.tsx +38 -15
- package/corpus/templates/forms/app/routes/_app.settings.tsx +1 -1
- package/corpus/templates/forms/changelog/2026-07-12-form-builder-surfaces-are-calmer-fields-stay-stable-on-hover.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-12-smoother-more-tactile-controls-throughout-animated-icon-and-.md +6 -0
- package/corpus/templates/mail/app/components/email/EmailThread.tsx +21 -13
- package/corpus/templates/mail/app/components/email/email-iframe-document.ts +18 -0
- package/dist/agent/durable-background.d.ts +10 -10
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +16 -16
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.js +19 -8
- package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
- package/dist/agent/engine/anthropic-engine.js +11 -3
- package/dist/agent/engine/anthropic-engine.js.map +1 -1
- package/dist/agent/engine/output-tokens.js +6 -6
- package/dist/agent/engine/output-tokens.js.map +1 -1
- package/dist/agent/production-agent.d.ts +52 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +154 -26
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +9 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +71 -1
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +9 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +243 -48
- package/dist/agent/run-store.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +6 -2
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +21 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +298 -67
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +146 -1
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +11 -0
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +2 -1
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/chat/tool-call-display.d.ts +3 -1
- package/dist/client/chat/tool-call-display.d.ts.map +1 -1
- package/dist/client/chat/tool-call-display.js +13 -37
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/conversation/use-near-bottom-autoscroll.d.ts +1 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.d.ts.map +1 -1
- package/dist/client/conversation/use-near-bottom-autoscroll.js +6 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.js.map +1 -1
- package/dist/client/extensions/AgentNativeExtensionFrame.d.ts.map +1 -1
- package/dist/client/extensions/AgentNativeExtensionFrame.js +2 -1
- package/dist/client/extensions/AgentNativeExtensionFrame.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +2 -1
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/client/extensions/ExtensionEditor.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionEditor.js +5 -2
- package/dist/client/extensions/ExtensionEditor.js.map +1 -1
- package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionViewer.js +2 -1
- package/dist/client/extensions/ExtensionViewer.js.map +1 -1
- package/dist/client/extensions/InlineExtensionFrame.d.ts.map +1 -1
- package/dist/client/extensions/InlineExtensionFrame.js +2 -1
- package/dist/client/extensions/InlineExtensionFrame.js.map +1 -1
- package/dist/client/extensions/portable-extension.d.ts.map +1 -1
- package/dist/client/extensions/portable-extension.js +2 -0
- package/dist/client/extensions/portable-extension.js.map +1 -1
- package/dist/client/session-replay.d.ts +12 -3
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +793 -49
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/settings/DemoModeSection.js +1 -1
- package/dist/client/settings/DemoModeSection.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +5 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +37 -1
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/demo/actions/toggle-demo-mode.js +2 -2
- package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
- package/dist/demo/config.js +6 -6
- package/dist/demo/config.js.map +1 -1
- package/dist/demo/fetch-interceptor.d.ts +1 -0
- package/dist/demo/fetch-interceptor.d.ts.map +1 -1
- package/dist/demo/fetch-interceptor.js +28 -2
- package/dist/demo/fetch-interceptor.js.map +1 -1
- package/dist/demo/redact.d.ts +14 -7
- package/dist/demo/redact.d.ts.map +1 -1
- package/dist/demo/redact.js +27 -155
- package/dist/demo/redact.js.map +1 -1
- package/dist/extensions/html-shell.d.ts.map +1 -1
- package/dist/extensions/html-shell.js +2 -0
- package/dist/extensions/html-shell.js.map +1 -1
- package/dist/extensions/session-replay-iframe.d.ts +10 -0
- package/dist/extensions/session-replay-iframe.d.ts.map +1 -0
- package/dist/extensions/session-replay-iframe.js +121 -0
- package/dist/extensions/session-replay-iframe.js.map +1 -0
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/scripts/agent-engines/list-agent-engines.d.ts.map +1 -1
- package/dist/scripts/agent-engines/list-agent-engines.js +1 -19
- package/dist/scripts/agent-engines/list-agent-engines.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/session-replay-iframe-protocol.d.ts +36 -0
- package/dist/session-replay-iframe-protocol.d.ts.map +1 -0
- package/dist/session-replay-iframe-protocol.js +24 -0
- package/dist/session-replay-iframe-protocol.js.map +1 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.d.ts.map +1 -1
- package/dist/shared/reasoning-effort.js +36 -0
- package/dist/shared/reasoning-effort.js.map +1 -1
- package/dist/styles/agent-conversation.css +26 -0
- package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
- package/docs/content/durable-background-runs.mdx +37 -0
- package/docs/content/locales/ar-SA/tracking.mdx +2 -0
- package/docs/content/locales/de-DE/tracking.mdx +2 -0
- package/docs/content/locales/es-ES/tracking.mdx +2 -0
- package/docs/content/locales/fr-FR/tracking.mdx +4 -2
- package/docs/content/locales/hi-IN/tracking.mdx +2 -0
- package/docs/content/locales/ja-JP/tracking.mdx +2 -0
- package/docs/content/locales/ko-KR/tracking.mdx +2 -0
- package/docs/content/locales/pt-BR/tracking.mdx +2 -0
- package/docs/content/locales/zh-CN/tracking.mdx +2 -0
- package/docs/content/locales/zh-TW/durable-background-runs.mdx +14 -0
- package/docs/content/locales/zh-TW/tracking.mdx +2 -0
- package/docs/content/tracking.mdx +2 -0
- package/package.json +2 -2
- package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +0 -15
- package/corpus/core/src/observability/hosted-model-experiment.ts +0 -118
- package/dist/observability/hosted-model-experiment.d.ts +0 -39
- package/dist/observability/hosted-model-experiment.d.ts.map +0 -1
- package/dist/observability/hosted-model-experiment.js +0 -90
- package/dist/observability/hosted-model-experiment.js.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SESSION_REPLAY_IFRAME_ATTRIBUTE, SESSION_REPLAY_IFRAME_PROBE, SESSION_REPLAY_IFRAME_START, SESSION_REPLAY_IFRAME_STOP, } from "../session-replay-iframe-protocol.js";
|
|
1
2
|
import { getOrCreateAnalyticsAnonymousId, getOrCreateAnalyticsSessionId, } from "./analytics-session.js";
|
|
2
3
|
import { scrubUrl } from "./url-scrub.js";
|
|
3
4
|
const DEFAULT_REPLAY_PATH = "/api/analytics/replay";
|
|
@@ -16,7 +17,9 @@ const DEFAULT_EVENT_SAMPLING = {
|
|
|
16
17
|
};
|
|
17
18
|
const SESSION_REPLAY_STATE_KEY = Symbol.for("agent-native.client.sessionReplay");
|
|
18
19
|
const SESSION_REPLAY_ID_STORAGE_KEY = "agent-native.session_replay_id";
|
|
20
|
+
const SESSION_REPLAY_IFRAME_BLOCK_SELECTOR = `iframe[${SESSION_REPLAY_IFRAME_ATTRIBUTE}]`;
|
|
19
21
|
const DEFAULT_BLOCK_SELECTOR = [
|
|
22
|
+
SESSION_REPLAY_IFRAME_BLOCK_SELECTOR,
|
|
20
23
|
"[data-sensitive]",
|
|
21
24
|
"[data-an-block]",
|
|
22
25
|
"[data-an-private]",
|
|
@@ -36,12 +39,34 @@ const DEFAULT_BLOCK_SELECTOR = [
|
|
|
36
39
|
const DEFAULT_IGNORE_SELECTOR = ".an-ignore, [data-an-ignore]";
|
|
37
40
|
const DEFAULT_MASK_TEXT_CLASS = "an-mask";
|
|
38
41
|
const DEFAULT_MASK_TEXT_SELECTOR = "[data-an-mask]";
|
|
42
|
+
const DEFAULT_MASK_INPUT_OPTIONS = {
|
|
43
|
+
color: true,
|
|
44
|
+
date: true,
|
|
45
|
+
"datetime-local": true,
|
|
46
|
+
email: true,
|
|
47
|
+
month: true,
|
|
48
|
+
number: true,
|
|
49
|
+
password: true,
|
|
50
|
+
range: true,
|
|
51
|
+
search: true,
|
|
52
|
+
tel: true,
|
|
53
|
+
text: true,
|
|
54
|
+
time: true,
|
|
55
|
+
url: true,
|
|
56
|
+
week: true,
|
|
57
|
+
};
|
|
39
58
|
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
|
40
59
|
const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000;
|
|
41
60
|
const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
|
|
42
61
|
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
|
|
43
62
|
const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
|
|
63
|
+
const REPLAY_TEXT_ENCODER = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
|
44
64
|
const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
|
|
65
|
+
/** Cross-tab channel name used by the duplicated-tab claim guard. */
|
|
66
|
+
const SESSION_REPLAY_BROADCAST_CHANNEL_NAME = "agent-native-session-replay";
|
|
67
|
+
/** How long a resuming tab waits for a "someone else already owns this
|
|
68
|
+
* replayId" reply before proceeding to record with the resumed id. */
|
|
69
|
+
const SESSION_REPLAY_CLAIM_TIMEOUT_MS = 150;
|
|
45
70
|
/** rrweb custom-event tag for captured console/window-error entries. */
|
|
46
71
|
export const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
|
|
47
72
|
/** rrweb custom-event tag for captured fetch/XHR request summaries. */
|
|
@@ -104,33 +129,78 @@ function getState() {
|
|
|
104
129
|
flushTimer: null,
|
|
105
130
|
maxDurationTimer: null,
|
|
106
131
|
flushing: false,
|
|
132
|
+
pendingFlushReason: null,
|
|
133
|
+
pendingFlushWaiters: [],
|
|
134
|
+
awaitingFullSnapshot: false,
|
|
107
135
|
stopRecorder: null,
|
|
108
136
|
restoreUrlMonitor: null,
|
|
109
137
|
removeLifecycleListeners: null,
|
|
138
|
+
restoreIframeBridge: null,
|
|
110
139
|
addCustomEvent: null,
|
|
111
140
|
restoreCaptures: null,
|
|
112
141
|
options: null,
|
|
113
142
|
lastAuthenticatedProperties: null,
|
|
143
|
+
resourceNodes: new Map(),
|
|
144
|
+
automaticConflictRestartAttempted: false,
|
|
145
|
+
broadcastChannel: null,
|
|
114
146
|
};
|
|
115
147
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
const state = g[SESSION_REPLAY_STATE_KEY];
|
|
149
|
+
// Keep Vite HMR safe when an older recorder state survives a module reload.
|
|
150
|
+
state.resourceNodes ??= new Map();
|
|
151
|
+
state.restoreIframeBridge ??= null;
|
|
152
|
+
state.pendingFlushReason ??= null;
|
|
153
|
+
state.pendingFlushWaiters ??= [];
|
|
154
|
+
state.awaitingFullSnapshot ??= false;
|
|
155
|
+
return state;
|
|
156
|
+
}
|
|
157
|
+
// The replay session record (replayId + sequence counter) lives in
|
|
158
|
+
// `sessionStorage`, not `localStorage`: `localStorage` is shared by every
|
|
159
|
+
// open tab of the origin, which would hand every tab the same `replayId` and
|
|
160
|
+
// the same sequence counter. See the guard comment above
|
|
161
|
+
// `getOrCreateReplaySession` for the corruption that causes.
|
|
162
|
+
function safeSessionStorageGet(key) {
|
|
119
163
|
try {
|
|
120
|
-
return window.
|
|
164
|
+
return window.sessionStorage.getItem(key);
|
|
121
165
|
}
|
|
122
166
|
catch {
|
|
123
167
|
return null;
|
|
124
168
|
}
|
|
125
169
|
}
|
|
126
|
-
function
|
|
170
|
+
function safeSessionStorageSet(key, value) {
|
|
171
|
+
try {
|
|
172
|
+
window.sessionStorage.setItem(key, value);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// private browsing / storage disabled -- replay still works for this page
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function safeSessionStorageRemove(key) {
|
|
127
179
|
try {
|
|
128
|
-
window.
|
|
180
|
+
window.sessionStorage.removeItem(key);
|
|
129
181
|
}
|
|
130
182
|
catch {
|
|
131
183
|
// private browsing / storage disabled -- replay still works for this page
|
|
132
184
|
}
|
|
133
185
|
}
|
|
186
|
+
let legacyLocalStorageReplaySessionCleared = false;
|
|
187
|
+
/**
|
|
188
|
+
* Best-effort, one-time removal of the pre-fix replay session record that
|
|
189
|
+
* used to live in `localStorage`. Never read from it -- adopting its
|
|
190
|
+
* `replayId` would recreate the exact shared-identity bug this file now
|
|
191
|
+
* avoids by using `sessionStorage` instead.
|
|
192
|
+
*/
|
|
193
|
+
function clearLegacyLocalStorageReplaySession() {
|
|
194
|
+
if (legacyLocalStorageReplaySessionCleared)
|
|
195
|
+
return;
|
|
196
|
+
legacyLocalStorageReplaySessionCleared = true;
|
|
197
|
+
try {
|
|
198
|
+
window.localStorage.removeItem(SESSION_REPLAY_ID_STORAGE_KEY);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// best-effort only -- a stray legacy record is harmless once ignored
|
|
202
|
+
}
|
|
203
|
+
}
|
|
134
204
|
function generateReplayId() {
|
|
135
205
|
try {
|
|
136
206
|
if (typeof crypto !== "undefined" &&
|
|
@@ -144,7 +214,7 @@ function generateReplayId() {
|
|
|
144
214
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
145
215
|
}
|
|
146
216
|
function readStoredReplaySession() {
|
|
147
|
-
const raw =
|
|
217
|
+
const raw = safeSessionStorageGet(SESSION_REPLAY_ID_STORAGE_KEY);
|
|
148
218
|
if (!raw)
|
|
149
219
|
return null;
|
|
150
220
|
try {
|
|
@@ -158,9 +228,37 @@ function readStoredReplaySession() {
|
|
|
158
228
|
}
|
|
159
229
|
}
|
|
160
230
|
function writeStoredReplaySession(value) {
|
|
161
|
-
|
|
231
|
+
safeSessionStorageSet(SESSION_REPLAY_ID_STORAGE_KEY, JSON.stringify(value));
|
|
232
|
+
}
|
|
233
|
+
function removeStoredReplaySession(replayId) {
|
|
234
|
+
if (readStoredReplaySession()?.replayId !== replayId)
|
|
235
|
+
return;
|
|
236
|
+
safeSessionStorageRemove(SESSION_REPLAY_ID_STORAGE_KEY);
|
|
162
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Per-tab replay identity is deliberate -- do not "fix" this by reading or
|
|
240
|
+
* writing the session record through `localStorage` again.
|
|
241
|
+
*
|
|
242
|
+
* `sessionStorage` is scoped to a single tab (and survives reloads/
|
|
243
|
+
* navigations within that tab, which is exactly the lifetime a recording
|
|
244
|
+
* needs). `localStorage` is shared by every open tab of the origin. If this
|
|
245
|
+
* record lived there, two tabs open to the same app would read/write the
|
|
246
|
+
* *same* `replayId` and the *same* sequence counter, so rrweb in each tab
|
|
247
|
+
* would record independently but upload chunks under one shared identity.
|
|
248
|
+
* The two interleaved DOM mutation streams get merged into a single
|
|
249
|
+
* recording server-side: mutations reference the other tab's node ids
|
|
250
|
+
* (broken CSS), the viewport/meta events reflect whichever tab resized last
|
|
251
|
+
* (wrong or ultra-wide viewport), and lost mousemove batches from the
|
|
252
|
+
* "other" tab's chunks read as a frozen cursor or a fake inactivity gap. A
|
|
253
|
+
* chunk-sequence collision with a different checksum gets rejected
|
|
254
|
+
* server-side (409) rather than merged, so one tab's batches are silently
|
|
255
|
+
* dropped -- there is no way to reconstruct or repair this at playback time.
|
|
256
|
+
* Keep this per-tab. A tab *duplicated* mid-session still shares a
|
|
257
|
+
* `sessionStorage` snapshot, which is what the `BroadcastChannel` claim
|
|
258
|
+
* check in `startSessionReplayRecorder` guards against.
|
|
259
|
+
*/
|
|
163
260
|
function getOrCreateReplaySession(sessionId) {
|
|
261
|
+
clearLegacyLocalStorageReplaySession();
|
|
164
262
|
const parsed = readStoredReplaySession();
|
|
165
263
|
if (parsed?.sessionId === sessionId && parsed.replayId) {
|
|
166
264
|
const startedAtMs = typeof parsed.startedAtMs === "number" &&
|
|
@@ -173,12 +271,115 @@ function getOrCreateReplaySession(sessionId) {
|
|
|
173
271
|
parsed.sequence >= 0
|
|
174
272
|
? Math.floor(parsed.sequence)
|
|
175
273
|
: 0;
|
|
176
|
-
return { replayId: parsed.replayId, startedAtMs, sequence };
|
|
274
|
+
return { replayId: parsed.replayId, startedAtMs, sequence, resumed: true };
|
|
177
275
|
}
|
|
178
276
|
const replayId = generateReplayId();
|
|
179
277
|
const startedAtMs = Date.now();
|
|
180
278
|
writeStoredReplaySession({ sessionId, replayId, startedAtMs, sequence: 0 });
|
|
181
|
-
return { replayId, startedAtMs, sequence: 0 };
|
|
279
|
+
return { replayId, startedAtMs, sequence: 0, resumed: false };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Open the cross-tab claim channel used to detect a *duplicated* tab (a
|
|
283
|
+
* browser "duplicate tab" or same-origin `window.open` copies
|
|
284
|
+
* `sessionStorage`, so two tabs can legitimately start with the same
|
|
285
|
+
* resumed `replayId`). Returns `null` when `BroadcastChannel` is
|
|
286
|
+
* unavailable -- callers must treat that as "skip the guard", never as an
|
|
287
|
+
* error.
|
|
288
|
+
*/
|
|
289
|
+
function openReplayBroadcastChannel() {
|
|
290
|
+
if (typeof BroadcastChannel === "undefined")
|
|
291
|
+
return null;
|
|
292
|
+
try {
|
|
293
|
+
return new BroadcastChannel(SESSION_REPLAY_BROADCAST_CHANNEL_NAME);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Wires up the duplicated-tab claim channel for one recorder lifetime.
|
|
301
|
+
* `respond` keeps listening for the whole life of the channel (any tab may
|
|
302
|
+
* later claim the `replayId` this tab is actively recording); `probeClaim`
|
|
303
|
+
* is used once, only when resuming a stored session, to ask "is anyone else
|
|
304
|
+
* already recording this id?" and wait up to
|
|
305
|
+
* `SESSION_REPLAY_CLAIM_TIMEOUT_MS` for a reply.
|
|
306
|
+
*/
|
|
307
|
+
function createReplayClaimChannel(state, instanceNonce) {
|
|
308
|
+
const channel = openReplayBroadcastChannel();
|
|
309
|
+
if (!channel) {
|
|
310
|
+
return { channel: null, probeClaim: async () => false };
|
|
311
|
+
}
|
|
312
|
+
let pending = null;
|
|
313
|
+
channel.onmessage = (event) => {
|
|
314
|
+
const data = event?.data;
|
|
315
|
+
if (!data || typeof data !== "object")
|
|
316
|
+
return;
|
|
317
|
+
if (data.type === "an-replay-claim") {
|
|
318
|
+
if (data.instanceNonce === instanceNonce)
|
|
319
|
+
return;
|
|
320
|
+
const ownsReplayId = state.active && state.replayId === data.replayId;
|
|
321
|
+
const winsSimultaneousClaim = pending?.replayId === data.replayId &&
|
|
322
|
+
instanceNonce.localeCompare(data.instanceNonce) < 0;
|
|
323
|
+
if (!ownsReplayId && !winsSimultaneousClaim) {
|
|
324
|
+
// If both duplicated tabs start simultaneously, deterministically
|
|
325
|
+
// yield to the lower nonce rather than letting both probes time out
|
|
326
|
+
// and record under the copied replay id.
|
|
327
|
+
if (pending?.replayId === data.replayId &&
|
|
328
|
+
data.instanceNonce.localeCompare(instanceNonce) < 0) {
|
|
329
|
+
window.clearTimeout(pending.timer);
|
|
330
|
+
const resolve = pending.resolve;
|
|
331
|
+
pending = null;
|
|
332
|
+
resolve(true);
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const reply = {
|
|
338
|
+
type: "an-replay-claim-taken",
|
|
339
|
+
replayId: data.replayId,
|
|
340
|
+
claimantNonce: data.instanceNonce,
|
|
341
|
+
};
|
|
342
|
+
channel.postMessage(reply);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// best-effort -- a lost reply just means the duplicate tab resumes
|
|
346
|
+
// recording under the shared id; later 409s still protect the
|
|
347
|
+
// stream from getting corrupted merges.
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (data.type === "an-replay-claim-taken") {
|
|
352
|
+
if (pending &&
|
|
353
|
+
data.replayId === pending.replayId &&
|
|
354
|
+
(!data.claimantNonce || data.claimantNonce === instanceNonce)) {
|
|
355
|
+
window.clearTimeout(pending.timer);
|
|
356
|
+
const resolve = pending.resolve;
|
|
357
|
+
pending = null;
|
|
358
|
+
resolve(true);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const probeClaim = (replayId) => new Promise((resolve) => {
|
|
363
|
+
const timer = window.setTimeout(() => {
|
|
364
|
+
pending = null;
|
|
365
|
+
resolve(false);
|
|
366
|
+
}, SESSION_REPLAY_CLAIM_TIMEOUT_MS);
|
|
367
|
+
pending = { replayId, resolve, timer };
|
|
368
|
+
try {
|
|
369
|
+
const claim = {
|
|
370
|
+
type: "an-replay-claim",
|
|
371
|
+
replayId,
|
|
372
|
+
instanceNonce,
|
|
373
|
+
};
|
|
374
|
+
channel.postMessage(claim);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
window.clearTimeout(timer);
|
|
378
|
+
pending = null;
|
|
379
|
+
resolve(false);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
return { channel, probeClaim };
|
|
182
383
|
}
|
|
183
384
|
function persistReplaySequence(sessionId, replayId, startedAtMs, sequence) {
|
|
184
385
|
writeStoredReplaySession({
|
|
@@ -349,22 +550,32 @@ function normalizeOptions(options) {
|
|
|
349
550
|
checkoutEveryNth: options.checkoutEveryNth,
|
|
350
551
|
checkoutEveryNms: options.checkoutEveryNms,
|
|
351
552
|
inlineStylesheet: options.inlineStylesheet ?? true,
|
|
352
|
-
blockSelector: options.blockSelector || DEFAULT_BLOCK_SELECTOR,
|
|
553
|
+
blockSelector: mergeReplayBlockSelector(options.blockSelector || DEFAULT_BLOCK_SELECTOR),
|
|
353
554
|
ignoreSelector: options.ignoreSelector || DEFAULT_IGNORE_SELECTOR,
|
|
354
555
|
maskTextClass: options.maskTextClass || DEFAULT_MASK_TEXT_CLASS,
|
|
355
556
|
maskTextSelector: options.maskTextSelector || DEFAULT_MASK_TEXT_SELECTOR,
|
|
356
557
|
maskAllInputs: options.maskAllInputs ?? true,
|
|
357
558
|
recordCanvas: options.recordCanvas ?? false,
|
|
358
|
-
|
|
559
|
+
// A top-level app can safely aggregate cooperative child recorders. An
|
|
560
|
+
// app embedded by an unrelated cross-origin host must continue emitting
|
|
561
|
+
// and uploading its own events, so it only forwards to a parent when the
|
|
562
|
+
// caller explicitly opts in.
|
|
563
|
+
recordCrossOriginIframes: options.recordCrossOriginIframes ?? window.parent === window,
|
|
359
564
|
collectFonts: options.collectFonts ?? false,
|
|
360
565
|
inlineImages: options.inlineImages ?? false,
|
|
361
566
|
eventSampling: options.eventSampling ?? DEFAULT_EVENT_SAMPLING,
|
|
362
567
|
console: normalizeCaptureToggle(options.console, DEFAULT_MAX_CONSOLE_EVENTS),
|
|
363
568
|
network: normalizeCaptureToggle(options.network, DEFAULT_MAX_NETWORK_EVENTS),
|
|
569
|
+
onUploadRejected: options.onUploadRejected,
|
|
364
570
|
extraProperties: options.extraProperties,
|
|
365
571
|
shouldStart: options.shouldStart,
|
|
366
572
|
};
|
|
367
573
|
}
|
|
574
|
+
function mergeReplayBlockSelector(blockSelector) {
|
|
575
|
+
return blockSelector.includes(SESSION_REPLAY_IFRAME_BLOCK_SELECTOR)
|
|
576
|
+
? blockSelector
|
|
577
|
+
: `${blockSelector}, ${SESSION_REPLAY_IFRAME_BLOCK_SELECTOR}`;
|
|
578
|
+
}
|
|
368
579
|
/**
|
|
369
580
|
* Console/network capture default to ON whenever session replay records;
|
|
370
581
|
* `false` disables a category, an object form overrides its caps. Network
|
|
@@ -424,17 +635,141 @@ function scrubReplayValue(value, key = "", depth = 0, seen = new WeakSet()) {
|
|
|
424
635
|
* the scrub into the single serialization pass avoids a separate deep-clone of
|
|
425
636
|
* every emitted event (FullSnapshots are large DOM trees) on the hot path.
|
|
426
637
|
*/
|
|
427
|
-
|
|
428
|
-
|
|
638
|
+
const REPLAY_RESOURCE_LINK_RELS = new Set([
|
|
639
|
+
"stylesheet",
|
|
640
|
+
"icon",
|
|
641
|
+
"apple-touch-icon",
|
|
642
|
+
"mask-icon",
|
|
643
|
+
]);
|
|
644
|
+
const REPLAY_RESOURCE_PRELOAD_TYPES = new Set([
|
|
645
|
+
"style",
|
|
646
|
+
"font",
|
|
647
|
+
"image",
|
|
648
|
+
"audio",
|
|
649
|
+
"video",
|
|
650
|
+
"track",
|
|
651
|
+
]);
|
|
652
|
+
const REPLAY_RESOURCE_TAGS = new Set([
|
|
653
|
+
"img",
|
|
654
|
+
"source",
|
|
655
|
+
"video",
|
|
656
|
+
"audio",
|
|
657
|
+
"track",
|
|
658
|
+
"input",
|
|
659
|
+
"link",
|
|
660
|
+
]);
|
|
661
|
+
const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set();
|
|
662
|
+
const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
|
|
663
|
+
const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
|
|
664
|
+
const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
|
|
665
|
+
const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
|
|
666
|
+
function replayAttributeString(attributes, key) {
|
|
667
|
+
return typeof attributes[key] === "string"
|
|
668
|
+
? attributes[key].toLowerCase()
|
|
669
|
+
: "";
|
|
670
|
+
}
|
|
671
|
+
function updateReplayResourceNode(current, attributes) {
|
|
672
|
+
return {
|
|
673
|
+
tagName: current.tagName,
|
|
674
|
+
rel: Object.hasOwn(attributes, "rel")
|
|
675
|
+
? replayAttributeString(attributes, "rel")
|
|
676
|
+
: current.rel,
|
|
677
|
+
as: Object.hasOwn(attributes, "as")
|
|
678
|
+
? replayAttributeString(attributes, "as")
|
|
679
|
+
: current.as,
|
|
680
|
+
type: Object.hasOwn(attributes, "type")
|
|
681
|
+
? replayAttributeString(attributes, "type")
|
|
682
|
+
: current.type,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function replayPreservedResourceAttributes(node) {
|
|
686
|
+
switch (node.tagName) {
|
|
687
|
+
case "img":
|
|
688
|
+
case "source":
|
|
689
|
+
return REPLAY_SRCSET_ATTRIBUTES;
|
|
690
|
+
case "video":
|
|
691
|
+
return REPLAY_VIDEO_ATTRIBUTES;
|
|
692
|
+
case "audio":
|
|
693
|
+
case "track":
|
|
694
|
+
return REPLAY_SRC_ATTRIBUTES;
|
|
695
|
+
case "input":
|
|
696
|
+
return node.type === "image"
|
|
697
|
+
? REPLAY_SRC_ATTRIBUTES
|
|
698
|
+
: NO_REPLAY_RESOURCE_ATTRIBUTES;
|
|
699
|
+
case "link": {
|
|
700
|
+
const rels = node.rel.split(/\s+/);
|
|
701
|
+
const isLoadBearingResource = rels.some((rel) => REPLAY_RESOURCE_LINK_RELS.has(rel)) ||
|
|
702
|
+
(rels.includes("preload") &&
|
|
703
|
+
REPLAY_RESOURCE_PRELOAD_TYPES.has(node.as));
|
|
704
|
+
return isLoadBearingResource
|
|
705
|
+
? REPLAY_HREF_ATTRIBUTES
|
|
706
|
+
: NO_REPLAY_RESOURCE_ATTRIBUTES;
|
|
707
|
+
}
|
|
708
|
+
default:
|
|
709
|
+
return NO_REPLAY_RESOURCE_ATTRIBUTES;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Build a path-aware replay serializer without cloning the rrweb event.
|
|
714
|
+
*
|
|
715
|
+
* Privacy still wins for Meta/navigation URLs, executable/embed URLs, anchor
|
|
716
|
+
* hrefs, and custom console/network diagnostics. The narrow exception is
|
|
717
|
+
* load-bearing stylesheet, font, image, and media attributes: changing those
|
|
718
|
+
* signed URLs makes rrweb rebuild a page that never existed. JSON.stringify
|
|
719
|
+
* calls a replacer for an `attributes` object before its children, so the
|
|
720
|
+
* WeakMap lets the child callback recognize only that bag.
|
|
721
|
+
*/
|
|
722
|
+
function createReplayScrubReplacer(resourceNodes) {
|
|
723
|
+
const preservedAttributes = new WeakMap();
|
|
724
|
+
return function replayScrubReplacer(key, value) {
|
|
725
|
+
if (key === "attributes" && value && typeof value === "object") {
|
|
726
|
+
const attributes = value;
|
|
727
|
+
const holder = this && typeof this === "object"
|
|
728
|
+
? this
|
|
729
|
+
: undefined;
|
|
730
|
+
const tagName = typeof holder?.tagName === "string" ? holder.tagName.toLowerCase() : "";
|
|
731
|
+
const nodeId = typeof holder?.id === "number" && Number.isFinite(holder.id)
|
|
732
|
+
? holder.id
|
|
733
|
+
: undefined;
|
|
734
|
+
let resourceNode;
|
|
735
|
+
if (tagName && REPLAY_RESOURCE_TAGS.has(tagName)) {
|
|
736
|
+
resourceNode = updateReplayResourceNode({ tagName, rel: "", as: "", type: "" }, attributes);
|
|
737
|
+
}
|
|
738
|
+
else if (nodeId !== undefined && !tagName) {
|
|
739
|
+
const current = resourceNodes.get(nodeId);
|
|
740
|
+
if (current) {
|
|
741
|
+
resourceNode = updateReplayResourceNode(current, attributes);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (nodeId !== undefined && resourceNode) {
|
|
745
|
+
resourceNodes.set(nodeId, resourceNode);
|
|
746
|
+
}
|
|
747
|
+
const resourceKeys = resourceNode
|
|
748
|
+
? replayPreservedResourceAttributes(resourceNode)
|
|
749
|
+
: NO_REPLAY_RESOURCE_ATTRIBUTES;
|
|
750
|
+
if (resourceKeys.size > 0)
|
|
751
|
+
preservedAttributes.set(value, resourceKeys);
|
|
752
|
+
return value;
|
|
753
|
+
}
|
|
754
|
+
if (typeof value === "string" &&
|
|
755
|
+
this &&
|
|
756
|
+
typeof this === "object" &&
|
|
757
|
+
preservedAttributes.get(this)?.has(key.toLowerCase())) {
|
|
758
|
+
return value;
|
|
759
|
+
}
|
|
760
|
+
return typeof value === "string" ? scrubStringValue(key, value) : value;
|
|
761
|
+
};
|
|
429
762
|
}
|
|
430
763
|
/**
|
|
431
764
|
* Serialize + scrub one event in a single pass. The resulting string is stored
|
|
432
765
|
* directly on the queue and reused verbatim at flush, so each event is
|
|
433
766
|
* stringified exactly once (was: deep-clone + size-stringify + flush-stringify).
|
|
434
767
|
*/
|
|
435
|
-
function serializeReplayEvent(event) {
|
|
768
|
+
function serializeReplayEvent(event, resourceNodes) {
|
|
436
769
|
try {
|
|
437
|
-
|
|
770
|
+
if (event.type === 2)
|
|
771
|
+
resourceNodes.clear();
|
|
772
|
+
return JSON.stringify(event, createReplayScrubReplacer(resourceNodes));
|
|
438
773
|
}
|
|
439
774
|
catch {
|
|
440
775
|
return "";
|
|
@@ -455,18 +790,25 @@ function replayEventTimestampMs(event) {
|
|
|
455
790
|
function enqueueReplayEvent(state, event) {
|
|
456
791
|
if (!state.options)
|
|
457
792
|
return;
|
|
458
|
-
const
|
|
793
|
+
const eventType = typeof event.type === "number" ? event.type : null;
|
|
794
|
+
if (state.awaitingFullSnapshot) {
|
|
795
|
+
if (eventType !== RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
|
|
796
|
+
return;
|
|
797
|
+
state.awaitingFullSnapshot = false;
|
|
798
|
+
}
|
|
799
|
+
const serialized = serializeReplayEvent(event, state.resourceNodes);
|
|
459
800
|
if (!serialized)
|
|
460
801
|
return;
|
|
461
|
-
const estimatedBytes = serialized
|
|
802
|
+
const estimatedBytes = replaySerializedBytes(serialized);
|
|
462
803
|
if (state.queue.length > 0 &&
|
|
463
804
|
state.queuedBytes + estimatedBytes > state.options.maxBatchBytes) {
|
|
464
805
|
void flushSessionReplay("max-bytes");
|
|
465
806
|
}
|
|
466
807
|
state.queue.push({
|
|
467
808
|
json: serialized,
|
|
809
|
+
byteLength: estimatedBytes,
|
|
468
810
|
timestampMs: replayEventTimestampMs(event),
|
|
469
|
-
type:
|
|
811
|
+
type: eventType,
|
|
470
812
|
});
|
|
471
813
|
state.queuedBytes += estimatedBytes;
|
|
472
814
|
flushQueuedReplayIfNeeded(state);
|
|
@@ -629,6 +971,25 @@ function replayUploadBodyBytes(body) {
|
|
|
629
971
|
function canUseReplayKeepalive(body) {
|
|
630
972
|
return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
|
|
631
973
|
}
|
|
974
|
+
/** Thrown by `sendReplayUpload` on a non-ok HTTP response, carrying the
|
|
975
|
+
* status so `flushSessionReplay` can tell a permanent client rejection
|
|
976
|
+
* (e.g. a 409 checksum conflict, which can never succeed on retry) apart
|
|
977
|
+
* from a transient failure worth retrying. */
|
|
978
|
+
class ReplayUploadHttpError extends Error {
|
|
979
|
+
status;
|
|
980
|
+
constructor(status) {
|
|
981
|
+
super(`Session replay upload failed with HTTP ${status}`);
|
|
982
|
+
this.name = "ReplayUploadHttpError";
|
|
983
|
+
this.status = status;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/** 4xx statuses where retrying the exact same batch can never succeed --
|
|
987
|
+
* e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
|
|
988
|
+
* reject again. 408 (timeout) and 429 (rate limit) are excluded because a
|
|
989
|
+
* later retry can plausibly succeed. */
|
|
990
|
+
function isDefinitiveReplayUploadClientError(status) {
|
|
991
|
+
return status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
992
|
+
}
|
|
632
993
|
async function sendReplayUpload(options, body, callbacks = {}) {
|
|
633
994
|
if (isCrossOriginReplayEndpoint(options.endpoint)) {
|
|
634
995
|
const canUseKeepalive = canUseReplayKeepalive(body);
|
|
@@ -641,7 +1002,7 @@ async function sendReplayUpload(options, body, callbacks = {}) {
|
|
|
641
1002
|
headers: { "Content-Type": "text/plain;charset=UTF-8" },
|
|
642
1003
|
});
|
|
643
1004
|
if (!response.ok) {
|
|
644
|
-
throw new
|
|
1005
|
+
throw new ReplayUploadHttpError(response.status);
|
|
645
1006
|
}
|
|
646
1007
|
return;
|
|
647
1008
|
}
|
|
@@ -659,7 +1020,7 @@ async function sendReplayUpload(options, body, callbacks = {}) {
|
|
|
659
1020
|
},
|
|
660
1021
|
});
|
|
661
1022
|
if (!response.ok) {
|
|
662
|
-
throw new
|
|
1023
|
+
throw new ReplayUploadHttpError(response.status);
|
|
663
1024
|
}
|
|
664
1025
|
}
|
|
665
1026
|
function isFinalFlushReason(reason) {
|
|
@@ -672,6 +1033,24 @@ function isFinalFlushReason(reason) {
|
|
|
672
1033
|
"max-duration",
|
|
673
1034
|
].includes(reason);
|
|
674
1035
|
}
|
|
1036
|
+
function flushReasonPriority(reason) {
|
|
1037
|
+
// Unload reasons must retain their keepalive sequence reservation even when
|
|
1038
|
+
// another final request (for example, an explicit manual stop) is coalesced.
|
|
1039
|
+
if (reason === "pagehide" || reason === "beforeunload")
|
|
1040
|
+
return 3;
|
|
1041
|
+
if (isFinalFlushReason(reason))
|
|
1042
|
+
return 2;
|
|
1043
|
+
if (reason === "visibility-hidden")
|
|
1044
|
+
return 1;
|
|
1045
|
+
return 0;
|
|
1046
|
+
}
|
|
1047
|
+
function mergePendingFlushReason(current, requested) {
|
|
1048
|
+
if (!current)
|
|
1049
|
+
return requested;
|
|
1050
|
+
return flushReasonPriority(requested) > flushReasonPriority(current)
|
|
1051
|
+
? requested
|
|
1052
|
+
: current;
|
|
1053
|
+
}
|
|
675
1054
|
function shouldReserveSequenceBeforeKeepalive(reason) {
|
|
676
1055
|
return (reason === "pagehide" ||
|
|
677
1056
|
reason === "beforeunload" ||
|
|
@@ -706,7 +1085,90 @@ function flushQueuedReplayIfNeeded(state) {
|
|
|
706
1085
|
void flushSessionReplay(reason);
|
|
707
1086
|
}
|
|
708
1087
|
function queuedReplayBytes(events) {
|
|
709
|
-
return events.reduce((total, event) => total + event
|
|
1088
|
+
return events.reduce((total, event) => total + queuedReplayEventBytes(event), 0);
|
|
1089
|
+
}
|
|
1090
|
+
function replaySerializedBytes(value) {
|
|
1091
|
+
if (REPLAY_TEXT_ENCODER)
|
|
1092
|
+
return REPLAY_TEXT_ENCODER.encode(value).byteLength;
|
|
1093
|
+
if (typeof Blob !== "undefined")
|
|
1094
|
+
return new Blob([value]).size;
|
|
1095
|
+
return value.length;
|
|
1096
|
+
}
|
|
1097
|
+
function queuedReplayEventBytes(event) {
|
|
1098
|
+
return Number.isFinite(event.byteLength)
|
|
1099
|
+
? event.byteLength
|
|
1100
|
+
: replaySerializedBytes(event.json);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Remove one bounded FIFO prefix from the live queue.
|
|
1104
|
+
*
|
|
1105
|
+
* Threshold-triggered flushes may arrive while another upload is active. The
|
|
1106
|
+
* old `queue.splice(0)` drained that entire accumulated backlog on the next
|
|
1107
|
+
* flush, bypassing both byte and event caps. Keep FullSnapshots isolated and
|
|
1108
|
+
* always take at least one event so an individually large snapshot can still
|
|
1109
|
+
* make progress.
|
|
1110
|
+
*/
|
|
1111
|
+
function takeQueuedReplayBatch(state) {
|
|
1112
|
+
const options = state.options;
|
|
1113
|
+
if (!options || state.queue.length === 0)
|
|
1114
|
+
return [];
|
|
1115
|
+
let count = 0;
|
|
1116
|
+
let bytes = 0;
|
|
1117
|
+
for (const event of state.queue) {
|
|
1118
|
+
if (count > 0 && event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
|
|
1119
|
+
break;
|
|
1120
|
+
if (count > 0 &&
|
|
1121
|
+
(count >= options.maxEventsPerBatch ||
|
|
1122
|
+
bytes + queuedReplayEventBytes(event) > options.maxBatchBytes)) {
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
count += 1;
|
|
1126
|
+
bytes += queuedReplayEventBytes(event);
|
|
1127
|
+
if (event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
const events = state.queue.splice(0, Math.max(1, count));
|
|
1131
|
+
state.queuedBytes = queuedReplayBytes(state.queue);
|
|
1132
|
+
return events;
|
|
1133
|
+
}
|
|
1134
|
+
function splitReplayBatch(events) {
|
|
1135
|
+
if (events.length < 2)
|
|
1136
|
+
return null;
|
|
1137
|
+
const targetBytes = queuedReplayBytes(events) / 2;
|
|
1138
|
+
let splitAt = 1;
|
|
1139
|
+
let bytes = events[0] ? queuedReplayEventBytes(events[0]) : 0;
|
|
1140
|
+
while (splitAt < events.length - 1 && bytes < targetBytes) {
|
|
1141
|
+
const event = events[splitAt];
|
|
1142
|
+
if (event)
|
|
1143
|
+
bytes += queuedReplayEventBytes(event);
|
|
1144
|
+
splitAt += 1;
|
|
1145
|
+
}
|
|
1146
|
+
return [events.slice(0, splitAt), events.slice(splitAt)];
|
|
1147
|
+
}
|
|
1148
|
+
function replayBatchNeedsDomReset(events) {
|
|
1149
|
+
return events.some((event) => {
|
|
1150
|
+
if (event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE)
|
|
1151
|
+
return true;
|
|
1152
|
+
if (event.type !== 3)
|
|
1153
|
+
return false;
|
|
1154
|
+
try {
|
|
1155
|
+
const parsed = JSON.parse(event.json);
|
|
1156
|
+
// rrweb IncrementalSource.Mutation is zero. Dropping one may leave later
|
|
1157
|
+
// node references dangling until another FullSnapshot resets the mirror.
|
|
1158
|
+
return parsed.data?.source === 0;
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
return true;
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
function quarantinePendingReplayUntilFullSnapshot(state) {
|
|
1166
|
+
const pending = [...state.retryBatches.flat(), ...state.queue];
|
|
1167
|
+
const resetAt = pending.findIndex((event) => event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE);
|
|
1168
|
+
state.retryBatches = [];
|
|
1169
|
+
state.queue = resetAt >= 0 ? pending.slice(resetAt) : [];
|
|
1170
|
+
state.queuedBytes = queuedReplayBytes(state.queue);
|
|
1171
|
+
state.awaitingFullSnapshot = resetAt < 0;
|
|
710
1172
|
}
|
|
711
1173
|
function restoreReplayEvents(state, events) {
|
|
712
1174
|
state.retryBatches.unshift(events);
|
|
@@ -727,10 +1189,17 @@ function rollbackReplaySequenceReservation(state, payload) {
|
|
|
727
1189
|
}
|
|
728
1190
|
export async function flushSessionReplay(reason = "manual") {
|
|
729
1191
|
const state = getState();
|
|
730
|
-
if (!state.options
|
|
1192
|
+
if (!state.options)
|
|
731
1193
|
return;
|
|
732
|
-
|
|
733
|
-
|
|
1194
|
+
if (state.flushing) {
|
|
1195
|
+
state.pendingFlushReason = mergePendingFlushReason(state.pendingFlushReason, reason);
|
|
1196
|
+
return new Promise((resolve) => {
|
|
1197
|
+
state.pendingFlushWaiters.push(resolve);
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
if (!hasPendingReplayBatch(state))
|
|
1201
|
+
return;
|
|
1202
|
+
const events = state.retryBatches.shift() ?? takeQueuedReplayBatch(state);
|
|
734
1203
|
const payload = buildReplayBody(state, reason, events);
|
|
735
1204
|
if (!payload || !state.options) {
|
|
736
1205
|
restoreReplayEvents(state, events);
|
|
@@ -739,6 +1208,9 @@ export async function flushSessionReplay(reason = "manual") {
|
|
|
739
1208
|
state.flushing = true;
|
|
740
1209
|
let uploaded = false;
|
|
741
1210
|
let reservedSequence = false;
|
|
1211
|
+
let splitRejectedBatch = false;
|
|
1212
|
+
let droppedOversizedBatch = false;
|
|
1213
|
+
let definitiveClientErrorStatus = null;
|
|
742
1214
|
try {
|
|
743
1215
|
await sendReplayUpload(state.options, payload.body, {
|
|
744
1216
|
beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
|
|
@@ -750,18 +1222,73 @@ export async function flushSessionReplay(reason = "manual") {
|
|
|
750
1222
|
});
|
|
751
1223
|
if (!reservedSequence)
|
|
752
1224
|
advanceReplaySequence(state, payload);
|
|
1225
|
+
state.automaticConflictRestartAttempted = false;
|
|
753
1226
|
uploaded = true;
|
|
754
1227
|
}
|
|
755
1228
|
catch (error) {
|
|
756
1229
|
if (reservedSequence)
|
|
757
1230
|
rollbackReplaySequenceReservation(state, payload);
|
|
758
|
-
|
|
1231
|
+
// A definitive 4xx (e.g. a 409 chunk-sequence/checksum conflict) can
|
|
1232
|
+
// never succeed by retrying the exact same batch -- requeuing it would
|
|
1233
|
+
// just spin forever, blocking every later batch behind it (flushes are
|
|
1234
|
+
// FIFO via `retryBatches`). Drop it and move on instead.
|
|
1235
|
+
const rejectedStatus = error instanceof ReplayUploadHttpError ? error.status : null;
|
|
1236
|
+
const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
|
|
1237
|
+
const isUnsplittableOversizedBatch = rejectedStatus === 413 && splitBatch === null;
|
|
1238
|
+
const isDefinitiveClientError = error instanceof ReplayUploadHttpError &&
|
|
1239
|
+
isDefinitiveReplayUploadClientError(error.status) &&
|
|
1240
|
+
!splitBatch &&
|
|
1241
|
+
!isUnsplittableOversizedBatch;
|
|
1242
|
+
if (splitBatch) {
|
|
1243
|
+
// A server or platform can enforce a stricter decompressed-body limit
|
|
1244
|
+
// than the recorder's configured queue cap. Bisect in FIFO order and
|
|
1245
|
+
// retry both halves at the same sequence; only successful halves advance
|
|
1246
|
+
// it, so no event is duplicated or skipped.
|
|
1247
|
+
state.retryBatches.unshift(...splitBatch);
|
|
1248
|
+
splitRejectedBatch = true;
|
|
1249
|
+
}
|
|
1250
|
+
else if (isUnsplittableOversizedBatch) {
|
|
1251
|
+
// This one event cannot be made any smaller. Drop only the rejected
|
|
1252
|
+
// singleton and keep later retry halves/live batches in FIFO order. If
|
|
1253
|
+
// it carried DOM structure, quarantine dependent mutations until a new
|
|
1254
|
+
// FullSnapshot can safely re-anchor rrweb's node mirror.
|
|
1255
|
+
droppedOversizedBatch = true;
|
|
1256
|
+
if (replayBatchNeedsDomReset(events)) {
|
|
1257
|
+
quarantinePendingReplayUntilFullSnapshot(state);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
else if (isDefinitiveClientError) {
|
|
1261
|
+
// Continuing after a checksum/sequence conflict would reuse the same
|
|
1262
|
+
// rejected sequence forever. More importantly, advancing past it would
|
|
1263
|
+
// append mutations to a replay whose DOM stream may belong to another
|
|
1264
|
+
// tab. End this recorder and clear its persisted identity so the next
|
|
1265
|
+
// start creates a clean replay instead of producing corrupt playback.
|
|
1266
|
+
state.queue = [];
|
|
1267
|
+
state.queuedBytes = 0;
|
|
1268
|
+
state.retryBatches = [];
|
|
1269
|
+
removeStoredReplaySession(payload.replayId);
|
|
1270
|
+
definitiveClientErrorStatus = error.status;
|
|
1271
|
+
}
|
|
1272
|
+
else {
|
|
1273
|
+
restoreReplayEvents(state, events);
|
|
1274
|
+
}
|
|
759
1275
|
// Guard the recorder's own warning so console capture never records it
|
|
760
1276
|
// (a captured warning would enqueue an event and retrigger a flush).
|
|
761
1277
|
const previousInternal = replayCaptureInternal;
|
|
762
1278
|
replayCaptureInternal = true;
|
|
763
1279
|
try {
|
|
764
|
-
|
|
1280
|
+
if (splitRejectedBatch) {
|
|
1281
|
+
console.warn("[session-replay] splitting oversized upload (HTTP 413)", error);
|
|
1282
|
+
}
|
|
1283
|
+
else if (droppedOversizedBatch) {
|
|
1284
|
+
console.warn("[session-replay] dropping oversized replay event (HTTP 413)", error);
|
|
1285
|
+
}
|
|
1286
|
+
else if (isDefinitiveClientError) {
|
|
1287
|
+
console.warn(`[session-replay] dropping upload (HTTP ${error.status})`, error);
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
console.warn("[session-replay] upload failed", error);
|
|
1291
|
+
}
|
|
765
1292
|
}
|
|
766
1293
|
finally {
|
|
767
1294
|
replayCaptureInternal = previousInternal;
|
|
@@ -770,9 +1297,112 @@ export async function flushSessionReplay(reason = "manual") {
|
|
|
770
1297
|
finally {
|
|
771
1298
|
state.flushing = false;
|
|
772
1299
|
}
|
|
773
|
-
|
|
774
|
-
|
|
1300
|
+
const coalescedReason = state.pendingFlushReason;
|
|
1301
|
+
const coalescedWaiters = coalescedReason
|
|
1302
|
+
? state.pendingFlushWaiters.splice(0)
|
|
1303
|
+
: [];
|
|
1304
|
+
if (coalescedReason)
|
|
1305
|
+
state.pendingFlushReason = null;
|
|
1306
|
+
if (coalescedReason) {
|
|
1307
|
+
// A stop/unload request that arrived during this upload owns the tail.
|
|
1308
|
+
// Its higher-priority reason must replace threshold/internal reasons so a
|
|
1309
|
+
// small final batch is not stranded or mislabeled as active.
|
|
1310
|
+
await flushSessionReplay(coalescedReason);
|
|
1311
|
+
}
|
|
1312
|
+
else if (splitRejectedBatch || droppedOversizedBatch) {
|
|
1313
|
+
// Preserve final-status and unload semantics through every half. In
|
|
1314
|
+
// particular, pagehide/beforeunload retries must still reserve their
|
|
1315
|
+
// sequence before a keepalive fetch, and completed flushes must not be
|
|
1316
|
+
// rewritten to an internal recovery reason/status.
|
|
1317
|
+
await flushSessionReplay(reason);
|
|
1318
|
+
}
|
|
1319
|
+
else if (uploaded && hasPendingReplayBatch(state)) {
|
|
1320
|
+
const mustContinue = state.retryBatches.length > 0 ||
|
|
1321
|
+
isFinalFlushReason(reason) ||
|
|
1322
|
+
shouldFlushQueuedReplay(state);
|
|
1323
|
+
if (mustContinue) {
|
|
1324
|
+
if (isFinalFlushReason(reason)) {
|
|
1325
|
+
await flushSessionReplay(reason);
|
|
1326
|
+
}
|
|
1327
|
+
else
|
|
1328
|
+
void flushSessionReplay(reason);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (definitiveClientErrorStatus !== null &&
|
|
1332
|
+
state.replayId === payload.replayId) {
|
|
1333
|
+
const rejectedOptions = state.options;
|
|
1334
|
+
const shouldRestartAfterConflict = definitiveClientErrorStatus === 409 &&
|
|
1335
|
+
state.active &&
|
|
1336
|
+
!isFinalFlushReason(reason) &&
|
|
1337
|
+
!state.automaticConflictRestartAttempted;
|
|
1338
|
+
if (shouldRestartAfterConflict) {
|
|
1339
|
+
state.automaticConflictRestartAttempted = true;
|
|
1340
|
+
}
|
|
1341
|
+
await stopSessionReplay("upload-rejected");
|
|
1342
|
+
// A 409 means this replay identity can no longer append safely (usually a
|
|
1343
|
+
// duplicated tab that inherited sessionStorage, or an old shared identity
|
|
1344
|
+
// still open during rollout). Do not leave a long-lived SPA tab silently
|
|
1345
|
+
// unrecorded until its next page load: restart rrweb under a fresh per-tab
|
|
1346
|
+
// id so it emits a new Meta + FullSnapshot stream. Limit this to one retry
|
|
1347
|
+
// until an upload succeeds; other definitive 4xx responses usually reflect
|
|
1348
|
+
// configuration/input errors and must not create a restart loop.
|
|
1349
|
+
let restartResult = null;
|
|
1350
|
+
if (shouldRestartAfterConflict && rejectedOptions) {
|
|
1351
|
+
restartResult = await restartSessionReplayAfterConflict(state, rejectedOptions, payload.sessionId);
|
|
1352
|
+
}
|
|
1353
|
+
// Rare recovery-path telemetry lets Analytics owners quantify conflicts
|
|
1354
|
+
// without recording the rejected replay id, URL, or any captured content.
|
|
1355
|
+
try {
|
|
1356
|
+
rejectedOptions?.onUploadRejected?.({
|
|
1357
|
+
status: definitiveClientErrorStatus,
|
|
1358
|
+
restartAttempted: shouldRestartAfterConflict,
|
|
1359
|
+
restartSucceeded: restartResult?.started === true,
|
|
1360
|
+
...(restartResult?.reason
|
|
1361
|
+
? { restartReason: restartResult.reason }
|
|
1362
|
+
: {}),
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
// best-effort telemetry must never interfere with recording recovery
|
|
1367
|
+
}
|
|
775
1368
|
}
|
|
1369
|
+
for (const resolve of coalescedWaiters)
|
|
1370
|
+
resolve();
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Restart a recorder whose replay identity was rejected without routing the
|
|
1374
|
+
* already-normalized options back through the public start API.
|
|
1375
|
+
*
|
|
1376
|
+
* The original recording already passed whole-session sampling. Re-entering
|
|
1377
|
+
* `startSessionReplay` here would ask `getOrCreateAnalyticsSessionId` again;
|
|
1378
|
+
* if the analytics session rotated while the upload was in flight, recovery
|
|
1379
|
+
* could be sampled out and leave a long-lived SPA tab silently unrecorded.
|
|
1380
|
+
* Keeping the original session id and accepted sampling decision also lets us
|
|
1381
|
+
* reuse the exact normalized console/network capture caps instead of relying
|
|
1382
|
+
* on internal option shapes continuing to round-trip through the public API.
|
|
1383
|
+
*/
|
|
1384
|
+
async function restartSessionReplayAfterConflict(state, options, sessionId) {
|
|
1385
|
+
if (options.shouldStart && !options.shouldStart()) {
|
|
1386
|
+
return { started: false, reason: "disabled", sessionId, sampled: true };
|
|
1387
|
+
}
|
|
1388
|
+
const initialProperties = replayExtraProperties(options);
|
|
1389
|
+
if (options.requireSignedInUser && !replayUserEmail(initialProperties)) {
|
|
1390
|
+
return {
|
|
1391
|
+
started: false,
|
|
1392
|
+
reason: "missing-user-id",
|
|
1393
|
+
sessionId,
|
|
1394
|
+
sampled: true,
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
if (!isUrlRecordable(window.location.href, options)) {
|
|
1398
|
+
return {
|
|
1399
|
+
started: false,
|
|
1400
|
+
reason: "url-blocked",
|
|
1401
|
+
sessionId,
|
|
1402
|
+
sampled: true,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
return startSessionReplayRecorder(state, options, sessionId, true, initialProperties);
|
|
776
1406
|
}
|
|
777
1407
|
function installUrlMonitor(state) {
|
|
778
1408
|
if (!state.options || state.restoreUrlMonitor)
|
|
@@ -822,6 +1452,80 @@ function installLifecycleListeners(state) {
|
|
|
822
1452
|
state.removeLifecycleListeners = null;
|
|
823
1453
|
};
|
|
824
1454
|
}
|
|
1455
|
+
function markedSessionReplayIframes() {
|
|
1456
|
+
if (typeof document.querySelectorAll !== "function")
|
|
1457
|
+
return [];
|
|
1458
|
+
return Array.from(document.querySelectorAll(`iframe[${SESSION_REPLAY_IFRAME_ATTRIBUTE}]`));
|
|
1459
|
+
}
|
|
1460
|
+
function markedSessionReplayIframeForSource(source) {
|
|
1461
|
+
if (!source)
|
|
1462
|
+
return null;
|
|
1463
|
+
return (markedSessionReplayIframes().find((iframe) => iframe.contentWindow === source) ?? null);
|
|
1464
|
+
}
|
|
1465
|
+
function sessionReplayIframePrivacyOptions(options) {
|
|
1466
|
+
return {
|
|
1467
|
+
blockSelector: options.blockSelector,
|
|
1468
|
+
ignoreSelector: options.ignoreSelector,
|
|
1469
|
+
maskTextClass: options.maskTextClass,
|
|
1470
|
+
maskTextSelector: options.maskTextSelector,
|
|
1471
|
+
maskAllInputs: options.maskAllInputs,
|
|
1472
|
+
maskInputOptions: DEFAULT_MASK_INPUT_OPTIONS,
|
|
1473
|
+
recordCanvas: options.recordCanvas,
|
|
1474
|
+
collectFonts: options.collectFonts,
|
|
1475
|
+
inlineImages: options.inlineImages,
|
|
1476
|
+
sampling: options.eventSampling,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
function postSessionReplayIframeMessage(iframe, message) {
|
|
1480
|
+
try {
|
|
1481
|
+
iframe.contentWindow?.postMessage(message, "*");
|
|
1482
|
+
}
|
|
1483
|
+
catch {
|
|
1484
|
+
// The frame may have navigated or detached between discovery and send.
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Cooperative opaque/cross-origin iframe recording.
|
|
1489
|
+
*
|
|
1490
|
+
* rrweb cannot inspect these documents from the host. Framework-owned child
|
|
1491
|
+
* frames carry a marker and inject a tiny recorder that probes its direct
|
|
1492
|
+
* parent. Only an active host recorder responds, and only when the probe's
|
|
1493
|
+
* source is the contentWindow of a currently marked direct iframe.
|
|
1494
|
+
*/
|
|
1495
|
+
function installSessionReplayIframeBridge(state, options) {
|
|
1496
|
+
if (!options.recordCrossOriginIframes || state.restoreIframeBridge)
|
|
1497
|
+
return;
|
|
1498
|
+
const startMessage = {
|
|
1499
|
+
type: SESSION_REPLAY_IFRAME_START,
|
|
1500
|
+
options: sessionReplayIframePrivacyOptions(options),
|
|
1501
|
+
};
|
|
1502
|
+
const stopMessage = {
|
|
1503
|
+
type: SESSION_REPLAY_IFRAME_STOP,
|
|
1504
|
+
};
|
|
1505
|
+
const sendStart = (iframe) => postSessionReplayIframeMessage(iframe, startMessage);
|
|
1506
|
+
const onMessage = (event) => {
|
|
1507
|
+
if (!state.active)
|
|
1508
|
+
return;
|
|
1509
|
+
if (!event.data ||
|
|
1510
|
+
typeof event.data !== "object" ||
|
|
1511
|
+
event.data.type !== SESSION_REPLAY_IFRAME_PROBE) {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
const iframe = markedSessionReplayIframeForSource(event.source);
|
|
1515
|
+
if (iframe)
|
|
1516
|
+
sendStart(iframe);
|
|
1517
|
+
};
|
|
1518
|
+
window.addEventListener("message", onMessage);
|
|
1519
|
+
for (const iframe of markedSessionReplayIframes())
|
|
1520
|
+
sendStart(iframe);
|
|
1521
|
+
state.restoreIframeBridge = () => {
|
|
1522
|
+
window.removeEventListener("message", onMessage);
|
|
1523
|
+
for (const iframe of markedSessionReplayIframes()) {
|
|
1524
|
+
postSessionReplayIframeMessage(iframe, stopMessage);
|
|
1525
|
+
}
|
|
1526
|
+
state.restoreIframeBridge = null;
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
825
1529
|
function truncateCaptureText(value, maxLength) {
|
|
826
1530
|
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
|
827
1531
|
}
|
|
@@ -1503,7 +2207,31 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
|
|
|
1503
2207
|
if (normalized.shouldStart && !normalized.shouldStart()) {
|
|
1504
2208
|
return { started: false, reason: "disabled", sessionId, sampled };
|
|
1505
2209
|
}
|
|
1506
|
-
|
|
2210
|
+
let replaySession = getOrCreateReplaySession(sessionId);
|
|
2211
|
+
const instanceNonce = generateReplayId();
|
|
2212
|
+
const { channel: replayChannel, probeClaim } = createReplayClaimChannel(state, instanceNonce);
|
|
2213
|
+
// Only a *resumed* id needs the duplicated-tab check -- a freshly minted
|
|
2214
|
+
// id can never collide with a recorder that's already running, so this
|
|
2215
|
+
// never adds startup latency for the common "new tab" case.
|
|
2216
|
+
if (replaySession.resumed && replayChannel) {
|
|
2217
|
+
const taken = await probeClaim(replaySession.replayId);
|
|
2218
|
+
if (taken) {
|
|
2219
|
+
const freshReplayId = generateReplayId();
|
|
2220
|
+
const freshStartedAtMs = Date.now();
|
|
2221
|
+
writeStoredReplaySession({
|
|
2222
|
+
sessionId,
|
|
2223
|
+
replayId: freshReplayId,
|
|
2224
|
+
startedAtMs: freshStartedAtMs,
|
|
2225
|
+
sequence: 0,
|
|
2226
|
+
});
|
|
2227
|
+
replaySession = {
|
|
2228
|
+
replayId: freshReplayId,
|
|
2229
|
+
startedAtMs: freshStartedAtMs,
|
|
2230
|
+
sequence: 0,
|
|
2231
|
+
resumed: false,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
1507
2235
|
state.options = normalized;
|
|
1508
2236
|
state.replayId = replaySession.replayId;
|
|
1509
2237
|
state.startedAtMs = replaySession.startedAtMs;
|
|
@@ -1511,7 +2239,13 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
|
|
|
1511
2239
|
state.queue = [];
|
|
1512
2240
|
state.queuedBytes = 0;
|
|
1513
2241
|
state.retryBatches = [];
|
|
2242
|
+
state.pendingFlushReason = null;
|
|
2243
|
+
for (const resolve of state.pendingFlushWaiters.splice(0))
|
|
2244
|
+
resolve();
|
|
2245
|
+
state.awaitingFullSnapshot = false;
|
|
2246
|
+
state.resourceNodes.clear();
|
|
1514
2247
|
state.stopRecorder = null;
|
|
2248
|
+
state.broadcastChannel = replayChannel;
|
|
1515
2249
|
state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
|
|
1516
2250
|
? { ...initialProperties }
|
|
1517
2251
|
: null;
|
|
@@ -1532,22 +2266,7 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
|
|
|
1532
2266
|
recordCrossOriginIframes: normalized.recordCrossOriginIframes,
|
|
1533
2267
|
collectFonts: normalized.collectFonts,
|
|
1534
2268
|
inlineImages: normalized.inlineImages,
|
|
1535
|
-
maskInputOptions:
|
|
1536
|
-
color: true,
|
|
1537
|
-
date: true,
|
|
1538
|
-
"datetime-local": true,
|
|
1539
|
-
email: true,
|
|
1540
|
-
month: true,
|
|
1541
|
-
number: true,
|
|
1542
|
-
password: true,
|
|
1543
|
-
range: true,
|
|
1544
|
-
search: true,
|
|
1545
|
-
tel: true,
|
|
1546
|
-
text: true,
|
|
1547
|
-
time: true,
|
|
1548
|
-
url: true,
|
|
1549
|
-
week: true,
|
|
1550
|
-
},
|
|
2269
|
+
maskInputOptions: DEFAULT_MASK_INPUT_OPTIONS,
|
|
1551
2270
|
});
|
|
1552
2271
|
if (typeof stopRecorder !== "function") {
|
|
1553
2272
|
state.active = false;
|
|
@@ -1555,6 +2274,13 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
|
|
|
1555
2274
|
state.replayId = null;
|
|
1556
2275
|
state.startedAtMs = null;
|
|
1557
2276
|
state.lastAuthenticatedProperties = null;
|
|
2277
|
+
try {
|
|
2278
|
+
state.broadcastChannel?.close();
|
|
2279
|
+
}
|
|
2280
|
+
catch {
|
|
2281
|
+
// best-effort cleanup
|
|
2282
|
+
}
|
|
2283
|
+
state.broadcastChannel = null;
|
|
1558
2284
|
return { started: false, reason: "record-failed", sessionId, sampled };
|
|
1559
2285
|
}
|
|
1560
2286
|
state.stopRecorder = stopRecorder;
|
|
@@ -1562,6 +2288,7 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
|
|
|
1562
2288
|
state.maxDurationTimer = window.setTimeout(() => stopSessionReplay("max-duration"), normalized.maxDurationMs);
|
|
1563
2289
|
installUrlMonitor(state);
|
|
1564
2290
|
installLifecycleListeners(state);
|
|
2291
|
+
installSessionReplayIframeBridge(state, normalized);
|
|
1565
2292
|
state.addCustomEvent =
|
|
1566
2293
|
typeof rrweb.record.addCustomEvent === "function"
|
|
1567
2294
|
? rrweb.record.addCustomEvent
|
|
@@ -1582,12 +2309,20 @@ async function startSessionReplayRecorder(state, normalized, sessionId, sampled,
|
|
|
1582
2309
|
// best-effort interceptor teardown
|
|
1583
2310
|
}
|
|
1584
2311
|
state.restoreCaptures = null;
|
|
2312
|
+
state.restoreIframeBridge?.();
|
|
1585
2313
|
state.addCustomEvent = null;
|
|
1586
2314
|
state.active = false;
|
|
1587
2315
|
state.options = null;
|
|
1588
2316
|
state.replayId = null;
|
|
1589
2317
|
state.startedAtMs = null;
|
|
1590
2318
|
state.lastAuthenticatedProperties = null;
|
|
2319
|
+
try {
|
|
2320
|
+
state.broadcastChannel?.close();
|
|
2321
|
+
}
|
|
2322
|
+
catch {
|
|
2323
|
+
// best-effort cleanup
|
|
2324
|
+
}
|
|
2325
|
+
state.broadcastChannel = null;
|
|
1591
2326
|
return { started: false, reason: "record-failed", sessionId, sampled };
|
|
1592
2327
|
}
|
|
1593
2328
|
}
|
|
@@ -1605,6 +2340,7 @@ export async function stopSessionReplay(reason = "manual") {
|
|
|
1605
2340
|
// best-effort interceptor teardown
|
|
1606
2341
|
}
|
|
1607
2342
|
state.restoreCaptures = null;
|
|
2343
|
+
state.restoreIframeBridge?.();
|
|
1608
2344
|
state.active = false;
|
|
1609
2345
|
state.addCustomEvent = null;
|
|
1610
2346
|
try {
|
|
@@ -1624,6 +2360,13 @@ export async function stopSessionReplay(reason = "manual") {
|
|
|
1624
2360
|
}
|
|
1625
2361
|
state.restoreUrlMonitor?.();
|
|
1626
2362
|
state.removeLifecycleListeners?.();
|
|
2363
|
+
try {
|
|
2364
|
+
state.broadcastChannel?.close();
|
|
2365
|
+
}
|
|
2366
|
+
catch {
|
|
2367
|
+
// best-effort cleanup
|
|
2368
|
+
}
|
|
2369
|
+
state.broadcastChannel = null;
|
|
1627
2370
|
await flushSessionReplay(reason);
|
|
1628
2371
|
}
|
|
1629
2372
|
export function maybeStartSessionReplay(options = {}) {
|
|
@@ -1634,9 +2377,10 @@ export function isSessionReplayActive() {
|
|
|
1634
2377
|
}
|
|
1635
2378
|
/**
|
|
1636
2379
|
* The active session replay id when a recording is running, or the last one
|
|
1637
|
-
* persisted for this analytics session in `
|
|
1638
|
-
* capture uses this to tie each captured exception to the
|
|
1639
|
-
* in, so triage can jump straight to
|
|
2380
|
+
* persisted for this analytics session in this tab's `sessionStorage`.
|
|
2381
|
+
* First-party error capture uses this to tie each captured exception to the
|
|
2382
|
+
* replay it happened in, so triage can jump straight to
|
|
2383
|
+
* `/sessions/<recordingId>`.
|
|
1640
2384
|
*/
|
|
1641
2385
|
export function getSessionReplayId() {
|
|
1642
2386
|
const state = getState();
|