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