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