@sailfish-ai/recorder 1.10.5 → 1.10.7
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/dist/chunkSerializer.js +210 -0
- package/dist/chunks/chunkSerializer-BuEZW3N4.js +95 -0
- package/dist/chunks/chunkSerializer-BuEZW3N4.js.br +0 -0
- package/dist/chunks/chunkSerializer-BuEZW3N4.js.gz +0 -0
- package/dist/chunks/chunkSerializer-DM9muyGb.js +94 -0
- package/dist/chunks/chunkSerializer-DM9muyGb.js.br +0 -0
- package/dist/chunks/chunkSerializer-DM9muyGb.js.gz +0 -0
- package/dist/chunks/index-BlPJWz9y.js +2259 -0
- package/dist/chunks/index-BlPJWz9y.js.br +0 -0
- package/dist/chunks/index-BlPJWz9y.js.gz +0 -0
- package/dist/chunks/index-soHaKXF4.js +2287 -0
- package/dist/chunks/index-soHaKXF4.js.br +0 -0
- package/dist/chunks/index-soHaKXF4.js.gz +0 -0
- package/dist/inAppReportIssueModal/state.js +5 -2
- package/dist/index.js +55 -35
- package/dist/recorder.cjs +2 -2208
- package/dist/recorder.js +44 -2230
- package/dist/recorder.js.br +0 -0
- package/dist/recorder.js.gz +0 -0
- package/dist/recording.js +127 -18
- package/dist/scheduler.js +9 -0
- package/dist/types/chunkSerializer.d.ts +31 -0
- package/dist/types/index.d.ts +32 -1
- package/dist/types/recording.d.ts +3 -1
- package/dist/types/scheduler.d.ts +1 -0
- package/dist/types/websocket.d.ts +6 -0
- package/dist/utils.js +3 -1
- package/dist/websocket.js +83 -27
- package/package.json +1 -1
package/dist/recorder.js.br
CHANGED
|
Binary file
|
package/dist/recorder.js.gz
CHANGED
|
Binary file
|
package/dist/recording.js
CHANGED
|
@@ -3,7 +3,8 @@ import { EventType } from "@sailfish-rrweb/types";
|
|
|
3
3
|
import { Complete, DomContentEventId, DomContentSource, Loading, } from "./constants";
|
|
4
4
|
import { getCallerLocation, getCallerLocationFromTrace, } from "./sourceLocation";
|
|
5
5
|
import suppressConsoleLogsDuringCall from "./suppressConsoleLogsDuringCall";
|
|
6
|
-
import {
|
|
6
|
+
import { yieldToMain } from "./scheduler";
|
|
7
|
+
import { getCachedHrefNoQuery, initializeWebSocket, sendEvent } from "./websocket";
|
|
7
8
|
// Module-level reference to rrweb record, populated after dynamic import
|
|
8
9
|
let _record = null;
|
|
9
10
|
const MASK_CLASS = "sailfishSanitize";
|
|
@@ -82,13 +83,34 @@ function maskInputFn(text, node) {
|
|
|
82
83
|
}
|
|
83
84
|
return text;
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
// Cached sessionStorage values — avoids per-call sessionStorage reads on the hot path
|
|
87
|
+
let _ssCacheDirty = true;
|
|
88
|
+
let _ssPageVisitUUID = null;
|
|
89
|
+
let _ssPrevPageVisitUUID = null;
|
|
90
|
+
let _ssTabVisibilityChanged = null;
|
|
91
|
+
let _ssTabVisibilityState = null;
|
|
92
|
+
function _refreshSessionStorageCache() {
|
|
93
|
+
_ssPageVisitUUID = sessionStorage.getItem("pageVisitUUID");
|
|
94
|
+
_ssPrevPageVisitUUID = sessionStorage.getItem("prevPageVisitUUID");
|
|
95
|
+
_ssTabVisibilityChanged = sessionStorage.getItem("tabVisibilityChanged");
|
|
96
|
+
_ssTabVisibilityState = sessionStorage.getItem("tabVisibilityState");
|
|
97
|
+
_ssCacheDirty = false;
|
|
98
|
+
}
|
|
99
|
+
/** Call after writing to sessionStorage keys used by getUrlAndStoredUuids(). */
|
|
100
|
+
export function invalidateUrlCache() {
|
|
101
|
+
_ssCacheDirty = true;
|
|
102
|
+
}
|
|
103
|
+
export const getUrlAndStoredUuids = () => {
|
|
104
|
+
if (_ssCacheDirty)
|
|
105
|
+
_refreshSessionStorageCache();
|
|
106
|
+
return {
|
|
107
|
+
page_visit_uuid: _ssPageVisitUUID,
|
|
108
|
+
prev_page_visit_uuid: _ssPrevPageVisitUUID,
|
|
109
|
+
href: getCachedHrefNoQuery(),
|
|
110
|
+
tabVisibilityChanged: _ssTabVisibilityChanged,
|
|
111
|
+
tabVisibilityState: _ssTabVisibilityState,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
92
114
|
export function initializeDomContentEvents(sessionId) {
|
|
93
115
|
document.addEventListener("readystatechange", () => {
|
|
94
116
|
const timestamp = Date.now();
|
|
@@ -140,6 +162,8 @@ export function initializeDomContentEvents(sessionId) {
|
|
|
140
162
|
}
|
|
141
163
|
export async function initializeConsolePlugin(consoleRecordSettings, sessionId) {
|
|
142
164
|
const { getRecordConsolePlugin } = await import("@sailfish-rrweb/rrweb-plugin-console-record");
|
|
165
|
+
// Yield between dynamic import and observer setup
|
|
166
|
+
await yieldToMain();
|
|
143
167
|
const { name, observer } = getRecordConsolePlugin(consoleRecordSettings);
|
|
144
168
|
observer((payload) => {
|
|
145
169
|
const anyPayload = payload;
|
|
@@ -245,7 +269,7 @@ function createThrottledEmit(enabled, intervalMs = 1000) {
|
|
|
245
269
|
}
|
|
246
270
|
export async function initializeRecording(captureSettings, // TODO - Sibyl post-launch - replace type
|
|
247
271
|
// networkRecordSettings: NetworkRecordOptions,
|
|
248
|
-
backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker = false) {
|
|
272
|
+
backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker = false, chunkSnapshot = false) {
|
|
249
273
|
const webSocket = initializeWebSocket(backendApi, apiKey, sessionId, envValue, useWsWorker);
|
|
250
274
|
try {
|
|
251
275
|
// Create throttled emit wrapper (only for text edit events)
|
|
@@ -272,15 +296,100 @@ backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker
|
|
|
272
296
|
}
|
|
273
297
|
const { record } = await import("@sailfish-rrweb/rrweb-record-only");
|
|
274
298
|
_record = record;
|
|
275
|
-
record(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
299
|
+
// Yield before rrweb record() — the heaviest synchronous operation
|
|
300
|
+
await yieldToMain();
|
|
301
|
+
if (chunkSnapshot) {
|
|
302
|
+
// ── Chunked snapshot mode ──────────────────────────────────────
|
|
303
|
+
// Instead of rrweb's synchronous full-DOM snapshot (which blocks
|
|
304
|
+
// the main thread for 50-200ms+ on large pages), we:
|
|
305
|
+
// 1. Start rrweb with recordDOM:false (MutationObserver only)
|
|
306
|
+
// 2. Run our own async serializer with yield points
|
|
307
|
+
// 3. Emit Meta + FullSnapshot manually
|
|
308
|
+
// 4. Flush any mutations buffered during serialization
|
|
309
|
+
const { chunkedSnapshot } = await import("./chunkSerializer");
|
|
310
|
+
const mirror = record.mirror;
|
|
311
|
+
// Buffer mutations emitted by rrweb's MutationObserver during
|
|
312
|
+
// our async serialization, then flush after the FullSnapshot.
|
|
313
|
+
let isBuffering = true;
|
|
314
|
+
const mutationBuffer = [];
|
|
315
|
+
record({
|
|
316
|
+
emit(event) {
|
|
317
|
+
if (isBuffering) {
|
|
318
|
+
mutationBuffer.push(event);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
emitWithContext(event);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
maskInputOptions: { text: true },
|
|
325
|
+
maskInputFn,
|
|
326
|
+
maskTextClass: MASK_CLASS,
|
|
327
|
+
...captureSettings,
|
|
328
|
+
recordDOM: false, // after spread so it can't be overridden
|
|
329
|
+
});
|
|
330
|
+
const snapshotStartTime = Date.now();
|
|
331
|
+
const serializedDoc = await chunkedSnapshot(document, mirror, {
|
|
332
|
+
chunkSize: 500,
|
|
333
|
+
maxChunkMs: 16,
|
|
334
|
+
blockClass: captureSettings.blockClass,
|
|
335
|
+
blockSelector: captureSettings.blockSelector,
|
|
336
|
+
maskTextClass: captureSettings.maskTextClass ?? MASK_CLASS,
|
|
337
|
+
maskTextSelector: captureSettings.maskTextSelector,
|
|
338
|
+
});
|
|
339
|
+
if (serializedDoc) {
|
|
340
|
+
// Emit Meta event
|
|
341
|
+
emitWithContext({
|
|
342
|
+
type: EventType.Meta,
|
|
343
|
+
data: {
|
|
344
|
+
href: window.location.href,
|
|
345
|
+
width: document.documentElement.clientWidth ||
|
|
346
|
+
document.body.clientWidth,
|
|
347
|
+
height: document.documentElement.clientHeight ||
|
|
348
|
+
document.body.clientHeight,
|
|
349
|
+
},
|
|
350
|
+
timestamp: snapshotStartTime,
|
|
351
|
+
});
|
|
352
|
+
// Emit FullSnapshot event
|
|
353
|
+
emitWithContext({
|
|
354
|
+
type: EventType.FullSnapshot,
|
|
355
|
+
data: {
|
|
356
|
+
node: serializedDoc,
|
|
357
|
+
initialOffset: {
|
|
358
|
+
left: window.pageXOffset !== undefined
|
|
359
|
+
? window.pageXOffset
|
|
360
|
+
: document.documentElement.scrollLeft,
|
|
361
|
+
top: window.pageYOffset !== undefined
|
|
362
|
+
? window.pageYOffset
|
|
363
|
+
: document.documentElement.scrollTop,
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
timestamp: snapshotStartTime,
|
|
367
|
+
});
|
|
368
|
+
// Flush buffered mutations, then switch to direct emit
|
|
369
|
+
for (const event of mutationBuffer) {
|
|
370
|
+
emitWithContext(event);
|
|
371
|
+
}
|
|
372
|
+
isBuffering = false;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
// Serialization failed — continue without initial DOM snapshot.
|
|
376
|
+
// MutationObserver is still running, so ongoing changes are captured.
|
|
377
|
+
console.warn("[Sailfish] chunkSnapshot serialization failed; session continues without initial DOM snapshot");
|
|
378
|
+
isBuffering = false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// ── Normal snapshot mode ───────────────────────────────────────
|
|
383
|
+
record({
|
|
384
|
+
emit(event) {
|
|
385
|
+
emitWithContext(event);
|
|
386
|
+
},
|
|
387
|
+
maskInputOptions: { text: true },
|
|
388
|
+
maskInputFn,
|
|
389
|
+
maskTextClass: MASK_CLASS,
|
|
390
|
+
...captureSettings,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
284
393
|
};
|
|
285
394
|
if (deferRecordingStart) {
|
|
286
395
|
// Deterministic deferral: start heavy work only after page load completes,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Yields to the main thread to avoid long tasks (>50ms).
|
|
2
|
+
// Uses scheduler.yield() (Chrome 115+) with setTimeout(0) fallback.
|
|
3
|
+
export function yieldToMain() {
|
|
4
|
+
if (typeof globalThis !== "undefined" &&
|
|
5
|
+
globalThis.scheduler?.yield) {
|
|
6
|
+
return globalThis.scheduler.yield();
|
|
7
|
+
}
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
9
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Subset of rrweb's Mirror interface needed for registration */
|
|
2
|
+
export interface ChunkMirror {
|
|
3
|
+
add(node: Node, meta: {
|
|
4
|
+
id: number;
|
|
5
|
+
}): void;
|
|
6
|
+
hasNode(node: Node): boolean;
|
|
7
|
+
getId(node: Node): number;
|
|
8
|
+
}
|
|
9
|
+
export interface ChunkOptions {
|
|
10
|
+
/** Yield after this many nodes (default: 500) */
|
|
11
|
+
chunkSize?: number;
|
|
12
|
+
/** Yield if chunk exceeds this many ms (default: 16) */
|
|
13
|
+
maxChunkMs?: number;
|
|
14
|
+
/** rrweb blockClass — elements with this class are serialized as empty placeholders */
|
|
15
|
+
blockClass?: string;
|
|
16
|
+
/** rrweb blockSelector — CSS selector for blocked elements */
|
|
17
|
+
blockSelector?: string | null;
|
|
18
|
+
/** rrweb maskTextClass — text under elements with this class is masked */
|
|
19
|
+
maskTextClass?: string;
|
|
20
|
+
/** rrweb maskTextSelector — CSS selector for masked text containers */
|
|
21
|
+
maskTextSelector?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Async chunked DOM serializer. Walks the document tree producing an
|
|
25
|
+
* rrweb-compatible serialized node tree, yielding to the main thread
|
|
26
|
+
* every `chunkSize` nodes or `maxChunkMs` milliseconds (whichever first).
|
|
27
|
+
*
|
|
28
|
+
* Nodes removed between yield points are silently skipped.
|
|
29
|
+
* Returns null if the document cannot be serialized.
|
|
30
|
+
*/
|
|
31
|
+
export declare function chunkedSnapshot(doc: Document, mirror: ChunkMirror, options?: ChunkOptions): Promise<any | null>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const STORAGE_VERSION = 1;
|
|
|
5
5
|
export declare const DEFAULT_CAPTURE_SETTINGS: CaptureSettings;
|
|
6
6
|
export declare const DEFAULT_CONSOLE_RECORDING_SETTINGS: LogRecordOptions;
|
|
7
7
|
export declare function matchUrlWithWildcard(input: unknown, patterns: string[]): boolean;
|
|
8
|
-
export declare function startRecording({ apiKey, backendApi, domainsToPropagateHeaderTo, domainsToNotPropagateHeaderTo, serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody, captureResponseBodyMaxMb, captureStreamPrefixKb, captureStreamTimeoutMs, enableFiberTracking, deferRecordingStart, useWsWorker, }: {
|
|
8
|
+
export declare function startRecording({ apiKey, backendApi, domainsToPropagateHeaderTo, domainsToNotPropagateHeaderTo, serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody, captureResponseBodyMaxMb, captureStreamPrefixKb, captureStreamTimeoutMs, enableFiberTracking, deferRecording, deferRecordingStart, chunkSnapshot, useWsWorker, }: {
|
|
9
9
|
apiKey: string;
|
|
10
10
|
backendApi?: string;
|
|
11
11
|
domainsToPropagateHeaderTo?: string[];
|
|
@@ -20,7 +20,10 @@ export declare function startRecording({ apiKey, backendApi, domainsToPropagateH
|
|
|
20
20
|
captureResponseBodyMaxMb?: number;
|
|
21
21
|
captureStreamPrefixKb?: number;
|
|
22
22
|
captureStreamTimeoutMs?: number;
|
|
23
|
+
deferRecording?: boolean;
|
|
24
|
+
/** @deprecated Use `deferRecording` instead. */
|
|
23
25
|
deferRecordingStart?: boolean;
|
|
26
|
+
chunkSnapshot?: boolean;
|
|
24
27
|
useWsWorker?: boolean;
|
|
25
28
|
}): Promise<void>;
|
|
26
29
|
export declare const initRecorder: (options: {
|
|
@@ -40,7 +43,35 @@ export declare const initRecorder: (options: {
|
|
|
40
43
|
captureResponseBodyMaxMb?: number;
|
|
41
44
|
captureStreamPrefixKb?: number;
|
|
42
45
|
captureStreamTimeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Defer heavy DOM recording (rrweb snapshot + MutationObserver) until after
|
|
48
|
+
* the page reaches TTI. Network interceptors (XHR/fetch), error handlers,
|
|
49
|
+
* and console capture still install immediately.
|
|
50
|
+
*
|
|
51
|
+
* When enabled, rrweb recording starts after page load completes AND one of:
|
|
52
|
+
* - `requestIdleCallback` fires (browser is genuinely idle)
|
|
53
|
+
* - First user interaction (click, scroll, keydown, touchstart)
|
|
54
|
+
* - 10-second hard ceiling timer
|
|
55
|
+
*
|
|
56
|
+
* Recommended for pages with heavy initial load (Module Federation, SPAs).
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
deferRecording?: boolean;
|
|
60
|
+
/** @deprecated Use `deferRecording` instead. */
|
|
43
61
|
deferRecordingStart?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Break the initial DOM snapshot into chunks, yielding to the main thread
|
|
64
|
+
* between each chunk. Reduces the long-task duration of rrweb's initial
|
|
65
|
+
* full-DOM serialization on pages with large DOM trees (8 000+ nodes).
|
|
66
|
+
*
|
|
67
|
+
* Yields every 500 nodes or 16 ms, whichever comes first.
|
|
68
|
+
* Nodes removed between yield points are silently skipped.
|
|
69
|
+
*
|
|
70
|
+
* Best combined with `deferRecording: true` so the snapshot runs outside
|
|
71
|
+
* the Lighthouse measurement window entirely.
|
|
72
|
+
* @default false
|
|
73
|
+
*/
|
|
74
|
+
chunkSnapshot?: boolean;
|
|
44
75
|
useWsWorker?: boolean;
|
|
45
76
|
}) => Promise<void>;
|
|
46
77
|
export * from "./graphql";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { LogRecordOptions } from "@sailfish-rrweb/rrweb-plugin-console-record";
|
|
2
2
|
import type { WsHandle } from "./websocket";
|
|
3
|
+
/** Call after writing to sessionStorage keys used by getUrlAndStoredUuids(). */
|
|
4
|
+
export declare function invalidateUrlCache(): void;
|
|
3
5
|
export declare const getUrlAndStoredUuids: () => {
|
|
4
6
|
page_visit_uuid: string;
|
|
5
7
|
prev_page_visit_uuid: string;
|
|
@@ -10,4 +12,4 @@ export declare const getUrlAndStoredUuids: () => {
|
|
|
10
12
|
export declare function initializeDomContentEvents(sessionId: string): void;
|
|
11
13
|
export declare function initializeConsolePlugin(consoleRecordSettings: LogRecordOptions, sessionId: string): Promise<void>;
|
|
12
14
|
export declare function initializeRecording(captureSettings: any, // TODO - Sibyl post-launch - replace type
|
|
13
|
-
backendApi: string, apiKey: string, sessionId: string, envValue?: string, deferRecordingStart?: boolean, useWsWorker?: boolean): Promise<WsHandle>;
|
|
15
|
+
backendApi: string, apiKey: string, sessionId: string, envValue?: string, deferRecordingStart?: boolean, useWsWorker?: boolean, chunkSnapshot?: boolean): Promise<WsHandle>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function yieldToMain(): Promise<void>;
|
|
@@ -3,6 +3,12 @@ export interface WsHandle {
|
|
|
3
3
|
readyState: number;
|
|
4
4
|
close: () => void;
|
|
5
5
|
}
|
|
6
|
+
/** Install navigation listeners to keep the cached URL fresh. */
|
|
7
|
+
export declare function ensureHrefCache(): void;
|
|
8
|
+
/** Get cached href (falls back to live read if cache not initialized). */
|
|
9
|
+
export declare function getCachedHref(): string;
|
|
10
|
+
/** Get cached origin+pathname (no query/hash). */
|
|
11
|
+
export declare function getCachedHrefNoQuery(): string;
|
|
6
12
|
/**
|
|
7
13
|
* Clear stale function span tracking state (called from index.tsx to validate localStorage)
|
|
8
14
|
* This disables tracking and clears localStorage if backend says tracking is not active
|
package/dist/utils.js
CHANGED
|
@@ -37,7 +37,9 @@ export function buildBatches(queue, getSize, maxBytes) {
|
|
|
37
37
|
return batches;
|
|
38
38
|
}
|
|
39
39
|
export function eventSize(event) {
|
|
40
|
-
|
|
40
|
+
// Fast byte-length estimate: JSON string length ≈ UTF-8 byte size for ASCII-heavy
|
|
41
|
+
// telemetry data. Avoids Blob constructor overhead. Accurate within ~5% for typical payloads.
|
|
42
|
+
return JSON.stringify(event).length;
|
|
41
43
|
}
|
|
42
44
|
// guard against old third party libraries which redefine Date.now
|
|
43
45
|
let nowTimestamp = Date.now;
|
package/dist/websocket.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import ReconnectingWebSocket from "reconnecting-websocket";
|
|
2
2
|
import { readDebugFlag } from "./env";
|
|
3
|
-
import { deleteEventsByIds, getAllIndexedEvents,
|
|
3
|
+
import { deleteEventsByIds, getAllIndexedEvents, saveEventsToIDB, } from "./eventStore";
|
|
4
4
|
import { deleteNotifyMessageById, getAllNotifyMessages, saveNotifyMessageToIDB, } from "./notifyEventStore";
|
|
5
5
|
import { getOrSetSessionId } from "./session";
|
|
6
6
|
import { buildBatches, eventSize } from "./utils";
|
|
@@ -96,6 +96,72 @@ let webSocket = null;
|
|
|
96
96
|
let isDraining = false;
|
|
97
97
|
let inFlightFlush = null;
|
|
98
98
|
let flushIntervalId = null;
|
|
99
|
+
// ─── Cached window.location.href ──────────────────────────────────────────────
|
|
100
|
+
// Updated on navigation events (popstate, hashchange, pushState, replaceState)
|
|
101
|
+
// instead of reading the DOM property on every sendEvent() call.
|
|
102
|
+
let _cachedHref = "";
|
|
103
|
+
let _cachedHrefNoQuery = "";
|
|
104
|
+
let _hrefListenersInstalled = false;
|
|
105
|
+
function _updateHrefCache() {
|
|
106
|
+
_cachedHref = window.location.href;
|
|
107
|
+
_cachedHrefNoQuery = window.location.origin + window.location.pathname;
|
|
108
|
+
}
|
|
109
|
+
/** Install navigation listeners to keep the cached URL fresh. */
|
|
110
|
+
export function ensureHrefCache() {
|
|
111
|
+
if (_hrefListenersInstalled || typeof window === "undefined")
|
|
112
|
+
return;
|
|
113
|
+
_hrefListenersInstalled = true;
|
|
114
|
+
_updateHrefCache();
|
|
115
|
+
window.addEventListener("popstate", _updateHrefCache);
|
|
116
|
+
window.addEventListener("hashchange", _updateHrefCache);
|
|
117
|
+
const origPush = history.pushState;
|
|
118
|
+
history.pushState = function (...args) {
|
|
119
|
+
origPush.apply(this, args);
|
|
120
|
+
_updateHrefCache();
|
|
121
|
+
};
|
|
122
|
+
const origReplace = history.replaceState;
|
|
123
|
+
history.replaceState = function (...args) {
|
|
124
|
+
origReplace.apply(this, args);
|
|
125
|
+
_updateHrefCache();
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Get cached href (falls back to live read if cache not initialized). */
|
|
129
|
+
export function getCachedHref() {
|
|
130
|
+
return (_cachedHref ||
|
|
131
|
+
(typeof window !== "undefined" ? window.location.href : ""));
|
|
132
|
+
}
|
|
133
|
+
/** Get cached origin+pathname (no query/hash). */
|
|
134
|
+
export function getCachedHrefNoQuery() {
|
|
135
|
+
return (_cachedHrefNoQuery ||
|
|
136
|
+
(typeof window !== "undefined"
|
|
137
|
+
? window.location.origin + window.location.pathname
|
|
138
|
+
: ""));
|
|
139
|
+
}
|
|
140
|
+
// ─── IDB event batching ──────────────────────────────────────────────────────
|
|
141
|
+
// Queue events and flush in batches to avoid one IDB transaction per event.
|
|
142
|
+
const _idbEventQueue = [];
|
|
143
|
+
let _idbFlushTimer = null;
|
|
144
|
+
const IDB_BATCH_SIZE = 50;
|
|
145
|
+
const IDB_FLUSH_INTERVAL_MS = 100;
|
|
146
|
+
function queueEventForIDB(event) {
|
|
147
|
+
_idbEventQueue.push(event);
|
|
148
|
+
if (_idbEventQueue.length >= IDB_BATCH_SIZE) {
|
|
149
|
+
_flushIDBQueue();
|
|
150
|
+
}
|
|
151
|
+
else if (!_idbFlushTimer) {
|
|
152
|
+
_idbFlushTimer = setTimeout(_flushIDBQueue, IDB_FLUSH_INTERVAL_MS);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function _flushIDBQueue() {
|
|
156
|
+
if (_idbFlushTimer) {
|
|
157
|
+
clearTimeout(_idbFlushTimer);
|
|
158
|
+
_idbFlushTimer = null;
|
|
159
|
+
}
|
|
160
|
+
if (_idbEventQueue.length === 0)
|
|
161
|
+
return;
|
|
162
|
+
const batch = _idbEventQueue.splice(0);
|
|
163
|
+
saveEventsToIDB(batch);
|
|
164
|
+
}
|
|
99
165
|
// Function span tracking state (only manages enabled/disabled)
|
|
100
166
|
let funcSpanTrackingEnabled = false;
|
|
101
167
|
let funcSpanTimeoutId = null;
|
|
@@ -325,13 +391,9 @@ export async function flushBufferedEvents() {
|
|
|
325
391
|
if (!isWebSocketOpen(webSocket))
|
|
326
392
|
break;
|
|
327
393
|
const eventsToSend = batch.map((e) => {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
332
|
-
// Note: We do NOT add funcspan header to websocket events
|
|
333
|
-
// The header is only added to HTTP network requests
|
|
334
|
-
return event;
|
|
394
|
+
if (!e.data.appUrl)
|
|
395
|
+
e.data.appUrl = getCachedHref();
|
|
396
|
+
return e.data;
|
|
335
397
|
});
|
|
336
398
|
const idsToDelete = batch
|
|
337
399
|
.map((e) => e.id)
|
|
@@ -359,23 +421,19 @@ export async function flushBufferedEvents() {
|
|
|
359
421
|
}
|
|
360
422
|
// ─── Public send API ─────────────────────────────────────────────────────────
|
|
361
423
|
export function sendEvent(event) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
};
|
|
366
|
-
// Note: We do NOT add funcspan header to websocket events
|
|
367
|
-
// The header is only added to HTTP network requests (fetch/XMLHttpRequest)
|
|
368
|
-
// This is handled in index.tsx injectHeader function
|
|
424
|
+
// Mutate in place — event is ephemeral, avoids object spread allocation
|
|
425
|
+
if (!event.app_url)
|
|
426
|
+
event.app_url = getCachedHref();
|
|
369
427
|
if (isDraining || !isWebSocketOpen(webSocket)) {
|
|
370
|
-
|
|
428
|
+
queueEventForIDB(event);
|
|
371
429
|
return;
|
|
372
430
|
}
|
|
373
431
|
if (!wsSendPayload({
|
|
374
432
|
type: "event",
|
|
375
|
-
event
|
|
433
|
+
event,
|
|
376
434
|
mapUuid: window.sfMapUuid,
|
|
377
435
|
})) {
|
|
378
|
-
|
|
436
|
+
queueEventForIDB(event);
|
|
379
437
|
}
|
|
380
438
|
}
|
|
381
439
|
// ─── WebSocket event handlers (shared between worker + direct paths) ─────────
|
|
@@ -542,6 +600,7 @@ function handleWsMessage(rawData) {
|
|
|
542
600
|
}
|
|
543
601
|
// ─── WebSocket initialization ────────────────────────────────────────────────
|
|
544
602
|
export function initializeWebSocket(backendApi, apiKey, sessionId, envValue, useWsWorker = false) {
|
|
603
|
+
ensureHrefCache();
|
|
545
604
|
const wsHost = getWebSocketHost(backendApi);
|
|
546
605
|
const apiProtocol = new URL(backendApi).protocol;
|
|
547
606
|
const wsScheme = apiProtocol === "https:" ? "wss" : "ws";
|
|
@@ -622,17 +681,14 @@ export function sendMessage(message) {
|
|
|
622
681
|
if (!("sessionId" in message)) {
|
|
623
682
|
message.sessionId = getOrSetSessionId();
|
|
624
683
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
};
|
|
629
|
-
// Check isDraining to prevent out-of-order delivery during reconnection
|
|
630
|
-
// When draining buffered events, queue new messages to maintain order
|
|
684
|
+
// Mutate in place — avoids object spread allocation
|
|
685
|
+
if (!message.app_url)
|
|
686
|
+
message.app_url = getCachedHref();
|
|
631
687
|
if (isDraining || !isWebSocketOpen(webSocket)) {
|
|
632
|
-
saveNotifyMessageToIDB(JSON.stringify(
|
|
688
|
+
saveNotifyMessageToIDB(JSON.stringify(message));
|
|
633
689
|
}
|
|
634
|
-
else if (!wsSendPayload(
|
|
635
|
-
saveNotifyMessageToIDB(JSON.stringify(
|
|
690
|
+
else if (!wsSendPayload(message)) {
|
|
691
|
+
saveNotifyMessageToIDB(JSON.stringify(message));
|
|
636
692
|
}
|
|
637
693
|
}
|
|
638
694
|
function getWebSocketHost(url) {
|