@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,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
|
-
|
|
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
|
|
483
|
+
function safeSessionStorageSet(key: string, value: string): void {
|
|
363
484
|
try {
|
|
364
|
-
|
|
485
|
+
window.sessionStorage.setItem(key, value);
|
|
365
486
|
} catch {
|
|
366
|
-
|
|
487
|
+
// private browsing / storage disabled -- replay still works for this page
|
|
367
488
|
}
|
|
368
489
|
}
|
|
369
490
|
|
|
370
|
-
function
|
|
491
|
+
function safeSessionStorageRemove(key: string): void {
|
|
371
492
|
try {
|
|
372
|
-
window.
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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(
|
|
1196
|
+
function serializeReplayEvent(
|
|
1197
|
+
event: ReplayEvent,
|
|
1198
|
+
resourceNodes: Map<number, ReplayResourceNode>,
|
|
1199
|
+
): string {
|
|
745
1200
|
try {
|
|
746
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
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 `
|
|
2186
|
-
* capture uses this to tie each captured exception to the
|
|
2187
|
-
* in, so triage can jump straight to
|
|
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();
|