@sailfish-ai/recorder 1.7.20 → 1.7.22

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const DEBUG = import.meta.env.VITE_DEBUG ? import.meta.env.VITE_DEBUG : false;
2
2
  // import { NetworkRecordOptions } from "@sailfish-rrweb/rrweb-plugin-network-record";
3
3
  import { v4 as uuidv4 } from "uuid";
4
- import { NetworkRequestEventId, xSf3RidHeader } from "./constants";
4
+ import { NetworkRequestEventId, STATIC_EXTENSIONS, xSf3RidHeader, } from "./constants";
5
5
  import { gatherAndCacheDeviceInfo } from "./deviceInfo";
6
6
  import { fetchCaptureSettings, sendDomainsToNotPropagateHeaderTo, startRecordingSession, } from "./graphql";
7
7
  import { sendMapUuidIfAvailable } from "./mapUuid";
@@ -16,19 +16,115 @@ const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
16
16
  "*.googleapis.com",
17
17
  "*.amazonaws.com", // Exclude AWS S3
18
18
  "*.smooch.io", // Exclude smooch-related requests
19
- "*.zendesk.com", // Exclude zendesk-related requests
19
+ // Exclude zendesk-related requests
20
+ "*.zendesk.com",
21
+ "*.zdassets.com",
20
22
  ];
21
23
  const BAD_HTTP_STATUS = [
22
24
  400, // BAD REQUEST
23
25
  403, // FORBIDDEN
24
26
  ];
25
27
  const CORS_KEYWORD = "CORS";
26
- const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
27
- const DYNAMIC_EXCLUDED_HOSTS_KEY = "dynamicExcludedHosts";
28
+ export const STORAGE_VERSION = 1;
29
+ export const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
30
+ export const DYNAMIC_EXCLUDED_HOSTS_KEY = "dynamicExcludedHosts";
28
31
  const SF_API_KEY_FOR_UPDATE = "sailfishApiKey";
29
32
  const SF_BACKEND_API = "sailfishBackendApi";
30
33
  const INCLUDE = "include";
31
34
  const SAME_ORIGIN = "same-origin";
35
+ const ALLOWED_HEADERS_HEADER = "access-control-allow-headers";
36
+ const OPTIONS = "OPTIONS";
37
+ export const dynamicExcludedHosts = new Set();
38
+ export const dynamicPassedHosts = new Set();
39
+ // 1️⃣ dynamicExcludedHosts.add override
40
+ const originalExcludedAdd = dynamicExcludedHosts.add;
41
+ dynamicExcludedHosts.add = (host) => {
42
+ const cleaned = host?.trim();
43
+ if (!cleaned || dynamicExcludedHosts.has(cleaned)) {
44
+ return dynamicExcludedHosts;
45
+ }
46
+ // 1. Add to the excluded Set
47
+ originalExcludedAdd.call(dynamicExcludedHosts, cleaned);
48
+ // 2. If it was previously in passed, remove it
49
+ if (dynamicPassedHosts.has(cleaned)) {
50
+ dynamicPassedHosts.delete(cleaned);
51
+ }
52
+ // 3. Consolidate dynamic exclusions into smart wildcards
53
+ const consolidated = consolidateDynamicExclusions(dynamicExcludedHosts);
54
+ dynamicExcludedHosts.clear();
55
+ for (const p of consolidated) {
56
+ originalExcludedAdd.call(dynamicExcludedHosts, p);
57
+ }
58
+ // 4. Persist wrapper to localStorage
59
+ try {
60
+ // TODO decouple into two logical pieces
61
+ const wrapper = {
62
+ version: STORAGE_VERSION,
63
+ entries: {},
64
+ };
65
+ const now = Date.now();
66
+ for (const p of dynamicExcludedHosts) {
67
+ wrapper.entries[p] = now;
68
+ }
69
+ localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(wrapper));
70
+ }
71
+ catch (e) {
72
+ if (DEBUG)
73
+ console.warn("Persist dynamicExcludedHosts failed:", e);
74
+ }
75
+ // 5. Notify backend of the updated Set
76
+ updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
77
+ return dynamicExcludedHosts;
78
+ };
79
+ // 2️⃣ dynamicPassedHosts.add override (timestamps refreshed on every call)
80
+ const originalPassedAdd = dynamicPassedHosts.add;
81
+ dynamicPassedHosts.add = (host) => {
82
+ const cleaned = host?.trim();
83
+ if (!cleaned) {
84
+ return dynamicPassedHosts;
85
+ }
86
+ // 1. Persist wrapper to localStorage (update timestamp unconditionally)
87
+ try {
88
+ // TODO decouple into two logical pieces
89
+ const raw = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
90
+ let wrapper;
91
+ if (!raw) {
92
+ wrapper = { version: STORAGE_VERSION, entries: {} };
93
+ }
94
+ else {
95
+ const parsed = JSON.parse(raw);
96
+ if (Array.isArray(parsed)) {
97
+ // migrate old array → wrapper
98
+ wrapper = {
99
+ version: STORAGE_VERSION,
100
+ entries: Object.fromEntries(parsed.map((h) => [h, Date.now()])),
101
+ };
102
+ }
103
+ else if (parsed &&
104
+ typeof parsed === "object" &&
105
+ "entries" in parsed &&
106
+ typeof parsed.entries === "object") {
107
+ wrapper = parsed;
108
+ }
109
+ else {
110
+ wrapper = { version: STORAGE_VERSION, entries: {} };
111
+ }
112
+ }
113
+ // always set/update the timestamp
114
+ wrapper.entries[cleaned] = Date.now();
115
+ wrapper.version = STORAGE_VERSION;
116
+ localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(wrapper));
117
+ }
118
+ catch (e) {
119
+ if (DEBUG)
120
+ console.warn("Persist dynamicPassedHosts failed:", e);
121
+ }
122
+ // 2. Add to the passed Set if not already present
123
+ if (!dynamicPassedHosts.has(cleaned)) {
124
+ originalPassedAdd.call(dynamicPassedHosts, cleaned);
125
+ }
126
+ return dynamicPassedHosts;
127
+ };
32
128
  /**
33
129
  * Notify the backend of the updated dynamicExcludedHosts
34
130
  */
@@ -40,58 +136,114 @@ function updateExcludedHostsStorageAndBackend(dynamicExcludedHosts) {
40
136
  }
41
137
  sendDomainsToNotPropagateHeaderTo(apiKeyForUpdate, [...dynamicExcludedHosts, ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT], apiForUpdate).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
42
138
  }
43
- const dynamicExcludedHosts = new Set();
44
- const dynamicPassedHosts = new Set();
45
- // Load initial dynamicExcludedHosts from localStorage
139
+ /**
140
+ * Given a set of excluded hostPaths (like "foo.com/bar", "foo.com/baz", etc.),
141
+ * find any path prefixes under each host that appear ≥ threshold times,
142
+ * and replace their individual entries with a single wildcard pattern
143
+ * (e.g. "foo.com/bar*").
144
+ */
145
+ export function consolidateDynamicExclusions(hostPathSet, threshold = 3) {
146
+ // 1️⃣ Group by host
147
+ const byHost = {};
148
+ for (const hostPath of hostPathSet) {
149
+ const [host, ...rest] = hostPath.split("/");
150
+ const path = rest.length ? `/${rest.join("/")}` : "/";
151
+ (byHost[host] ??= []).push(path);
152
+ }
153
+ const newSet = new Set();
154
+ for (const host in byHost) {
155
+ const paths = byHost[host];
156
+ if (paths.length < threshold) {
157
+ // not enough entries to bother; keep them as-is
158
+ for (const p of paths)
159
+ newSet.add(`${host}${p}`);
160
+ continue;
161
+ }
162
+ const root = { count: 0, children: new Map() };
163
+ for (const p of paths) {
164
+ root.count++;
165
+ const segments = p.split("/").filter(Boolean);
166
+ let node = root;
167
+ for (const seg of segments) {
168
+ if (!node.children.has(seg)) {
169
+ node.children.set(seg, { count: 0, children: new Map() });
170
+ }
171
+ node = node.children.get(seg);
172
+ node.count++;
173
+ }
174
+ }
175
+ // 3️⃣ Walk the trie and pick prefixes where node.count ≥ threshold
176
+ function gather(node, prefixSegments) {
177
+ // if this node covers ≥ threshold of this host’s paths, collapse here
178
+ if (node.count >= threshold && prefixSegments.length > 0) {
179
+ const wildcardPath = "/" + prefixSegments.join("/") + "/*";
180
+ newSet.add(`${host}${wildcardPath}`);
181
+ return;
182
+ }
183
+ // otherwise, recurse into children
184
+ for (const [seg, child] of node.children) {
185
+ gather(child, prefixSegments.concat(seg));
186
+ }
187
+ }
188
+ gather(root, []);
189
+ }
190
+ return newSet;
191
+ }
192
+ // 2️⃣ Load & evict old entries (>7 days) + version check for Excluded
46
193
  (() => {
47
194
  const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
48
- if (stored) {
49
- try {
50
- JSON.parse(stored).forEach((host) => dynamicExcludedHosts.add(host));
51
- }
52
- catch (e) {
53
- if (DEBUG)
54
- console.log("Failed to parse dynamicExcludedHosts from storage", e);
195
+ if (!stored)
196
+ return;
197
+ try {
198
+ const wrapper = JSON.parse(stored);
199
+ // if it's from an old version, drop it
200
+ if (wrapper.version !== STORAGE_VERSION) {
55
201
  localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
202
+ return;
56
203
  }
204
+ const now = Date.now();
205
+ const valid = {};
206
+ for (const [host, ts] of Object.entries(wrapper.entries)) {
207
+ if (now - ts < 7 * 24 * 60 * 60 * 1000) {
208
+ dynamicExcludedHosts.add(host);
209
+ valid[host] = ts;
210
+ }
211
+ }
212
+ localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
213
+ }
214
+ catch (e) {
215
+ if (DEBUG)
216
+ console.warn("Failed to parse dynamicExcludedHosts:", e);
217
+ localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
57
218
  }
58
219
  })();
59
- // Load initial dynamicPassedHosts from localStorage
220
+ // 3️⃣ Load & evict old entries (>7 days) + version check for Passed
60
221
  (() => {
61
222
  const stored = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
62
- if (stored) {
63
- try {
64
- JSON.parse(stored).forEach((host) => dynamicPassedHosts.add(host));
65
- }
66
- catch (e) {
67
- if (DEBUG)
68
- console.log("Failed to parse dynamicPassedHosts from storage", e);
223
+ if (!stored)
224
+ return;
225
+ try {
226
+ const wrapper = JSON.parse(stored);
227
+ if (wrapper.version !== STORAGE_VERSION) {
69
228
  localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
229
+ return;
70
230
  }
231
+ const now = Date.now();
232
+ const valid = {};
233
+ for (const [host, ts] of Object.entries(wrapper.entries)) {
234
+ if (now - ts < 7 * 24 * 60 * 60 * 1000) {
235
+ dynamicPassedHosts.add(host);
236
+ valid[host] = ts;
237
+ }
238
+ }
239
+ localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
71
240
  }
72
- })();
73
- // Override add() to persist updates to localStorage
74
- const originalExcludedAdd = dynamicExcludedHosts.add;
75
- dynamicExcludedHosts.add = (host) => {
76
- const cleanedHost = host?.trim();
77
- if (!cleanedHost) {
78
- return dynamicExcludedHosts;
241
+ catch (e) {
242
+ if (DEBUG)
243
+ console.warn("Failed to parse dynamicPassedHosts:", e);
244
+ localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
79
245
  }
80
- originalExcludedAdd.call(dynamicExcludedHosts, cleanedHost);
81
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(Array.from(dynamicExcludedHosts)));
82
- updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
83
- return dynamicExcludedHosts;
84
- };
85
- const originalPassedAdd = dynamicPassedHosts.add;
86
- dynamicPassedHosts.add = (host) => {
87
- const cleanedHost = host?.trim();
88
- if (!cleanedHost) {
89
- return dynamicPassedHosts;
90
- }
91
- originalPassedAdd.call(dynamicPassedHosts, cleanedHost);
92
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
93
- return dynamicPassedHosts;
94
- };
246
+ })();
95
247
  const ActionType = {
96
248
  PROPAGATE: "propagate",
97
249
  IGNORE: "ignore",
@@ -197,7 +349,10 @@ function getOrSetUserDeviceUuid() {
197
349
  let userDeviceUuid = localStorage.getItem("sailfishUserDeviceUuid");
198
350
  if (!userDeviceUuid) {
199
351
  userDeviceUuid = uuidv4();
200
- localStorage.setItem("sailfishUserDeviceUuid", userDeviceUuid);
352
+ try {
353
+ localStorage.setItem("sailfishUserDeviceUuid", userDeviceUuid);
354
+ }
355
+ catch { }
201
356
  }
202
357
  return userDeviceUuid;
203
358
  }
@@ -295,65 +450,29 @@ export function matchUrlWithWildcard(url, patterns) {
295
450
  return true;
296
451
  });
297
452
  }
298
- function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo = []) {
299
- const combinedIgnoreDomains = [
453
+ function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo = []) {
454
+ const urlObj = new URL(url, window.location.href);
455
+ // 1️⃣ STATIC ASSET EXCLUSIONS (by comprehensive file extension list)
456
+ for (const ext of STATIC_EXTENSIONS) {
457
+ if (urlObj.pathname.toLowerCase().endsWith(ext)) {
458
+ return true;
459
+ }
460
+ }
461
+ // 2️⃣ WILDCARD-BASED EXCLUSION (domain + path)
462
+ // Pass patterns like ["*.cdn.com/*", "api.example.com/v1/*"]
463
+ const combinedPatterns = [
300
464
  ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
301
- ...domainsToNotPropagateHeadersTo,
465
+ ...domainsToNotPropagateHeaderTo,
302
466
  ];
303
- const defaultExcluded = matchUrlWithWildcard(url, combinedIgnoreDomains);
304
- if (defaultExcluded) {
467
+ if (matchUrlWithWildcard(url, combinedPatterns)) {
305
468
  return true;
306
469
  }
307
- const domain = new URL(url).hostname;
308
- // Check dynamically excluded hosts (those that reject the tracing header runtime)
309
- return dynamicExcludedHosts.has(domain);
310
- }
311
- /**
312
- * Performs an OPTIONS preflight check using XHR.
313
- * Returns ActionType.PROPAGATE if server responds 2xx, ActionType.IGNORE on error or non-2xx.
314
- */
315
- function performOptionsPreflightForXHR(url, init, xSf3RidHeaderValue, domain) {
316
- return new Promise((resolve) => {
317
- const xhr = new XMLHttpRequest();
318
- xhr.open("OPTIONS", url, true);
319
- // Mirror credentials
320
- xhr.withCredentials = init.credentials === INCLUDE;
321
- // CORS preflight headers
322
- const method = (init.method || "GET").toUpperCase();
323
- xhr.setRequestHeader("Access-Control-Request-Method", method);
324
- const rawHeaders = init.headers instanceof Headers
325
- ? Object.entries(init.headers)
326
- : init.headers || {};
327
- const customHeaders = Object.keys(rawHeaders)
328
- .map((h) => h.toLowerCase())
329
- .filter((h) => ![
330
- "accept",
331
- "content-type",
332
- "accept-language",
333
- "content-language",
334
- ].includes(h));
335
- if (customHeaders.length) {
336
- xhr.setRequestHeader("Access-Control-Request-Headers", customHeaders.join(","));
337
- }
338
- // Add tracing header
339
- this.setRequestHeader(xSf3RidHeader, xSf3RidHeaderValue);
340
- xhr.onload = () => {
341
- if (xhr.status >= 200 && xhr.status < 300) {
342
- resolve(ActionType.PROPAGATE);
343
- }
344
- else {
345
- DEBUG &&
346
- console.log(`[XHR Interceptor] OPTIONS returned status ${xhr.status} for ${domain}`);
347
- resolve(null);
348
- }
349
- };
350
- xhr.onerror = () => {
351
- DEBUG &&
352
- console.log(`[XHR Interceptor] Preflight OPTIONS CORS or network error for ${domain}`);
353
- resolve(ActionType.IGNORE);
354
- };
355
- xhr.send();
356
- });
470
+ // 3️⃣ DYNAMIC-FAILURE EXCLUSION (exact host)
471
+ const hostname = urlObj.hostname;
472
+ if (dynamicExcludedHosts.has(hostname)) {
473
+ return true;
474
+ }
475
+ return false;
357
476
  }
358
477
  // Updated XMLHttpRequest interceptor with domain exclusion
359
478
  function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
@@ -379,120 +498,101 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
379
498
  this._capturedRequestHeaders = {};
380
499
  return originalOpen.apply(this, [method, url, ...args]);
381
500
  };
382
- // Intercept send()
501
+ // 1️⃣ XHR interceptor send()
383
502
  XMLHttpRequest.prototype.send = function (...args) {
384
503
  const url = this._requestUrl;
385
- if (!url)
504
+ if (!url) {
386
505
  return originalSend.apply(this, args);
387
- const domain = new URL(url).hostname;
388
- // Skip domain check for excluded domains
506
+ }
507
+ // parse host+path for exclusion
508
+ const urlObj = new URL(url, window.location.href);
509
+ const domain = urlObj.hostname;
510
+ const hostPath = urlObj.pathname === "/" ? domain : `${domain}${urlObj.pathname}`;
511
+ // 1️⃣ Skip injection for excluded domains/paths
389
512
  if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo)) {
390
513
  return originalSend.apply(this, args);
391
514
  }
515
+ // 2️⃣ Prepare header and IDs
392
516
  const pageVisitUUID = sessionStorage.getItem("pageVisitUUID");
393
517
  const networkUUID = uuidv4();
394
- const xSf3RidHeaderValue = `${sessionId}/${pageVisitUUID}/${networkUUID}`;
395
- const proceedSend = () => {
396
- if (sessionId) {
397
- try {
398
- this.setRequestHeader(xSf3RidHeader, xSf3RidHeaderValue);
399
- }
400
- catch (e) {
401
- console.warn(`Could not set X-Sf3-Rid header for ${url}`, e);
402
- }
518
+ const headerValue = `${sessionId}/${pageVisitUUID}/${networkUUID}`;
519
+ try {
520
+ this.setRequestHeader(xSf3RidHeader, headerValue);
521
+ }
522
+ catch (e) {
523
+ console.warn(`Could not set X-Sf3-Rid for ${url}`, e);
524
+ }
525
+ // 3️⃣ Track timing
526
+ const startTime = Date.now();
527
+ let finished = false;
528
+ // 4️⃣ Helper to emit networkRequestFinished
529
+ const emitFinished = (success, status, errorMsg) => {
530
+ if (finished)
531
+ return;
532
+ finished = true;
533
+ const endTime = Date.now();
534
+ sendEvent({
535
+ type: NetworkRequestEventId,
536
+ timestamp: endTime,
537
+ sessionId,
538
+ data: {
539
+ request_id: networkUUID,
540
+ session_id: sessionId,
541
+ timestamp_start: startTime,
542
+ timestamp_end: endTime,
543
+ response_code: status,
544
+ success,
545
+ error: errorMsg,
546
+ method: this._requestMethod,
547
+ url,
548
+ },
549
+ ...getUrlAndStoredUuids(),
550
+ });
551
+ // 5️⃣ Update dynamic sets
552
+ if (success) {
553
+ // once any route passes, skip preflight on entire domain
554
+ dynamicPassedHosts.add(domain);
555
+ }
556
+ else {
557
+ // only exclude the specific failing path
558
+ dynamicExcludedHosts.add(hostPath);
403
559
  }
404
- // On CORS or network error during send, add domain to passed-hosts list
405
- this.addEventListener("error", () => {
406
- dynamicExcludedHosts.add(domain);
407
- }, { once: true });
408
- // On successful send (HTTP 2xx), add domain to passed-hosts list
409
- this.addEventListener("load", () => {
410
- if (this.status === 0) {
411
- // status 0 on load is often a CORS‐blocked response
412
- dynamicExcludedHosts.add(domain);
413
- }
414
- if (this.status >= 200 && this.status < 300) {
415
- dynamicPassedHosts.add(domain);
416
- }
417
- }, { once: true });
418
- return originalSend.apply(this, args);
419
560
  };
420
- if (!dynamicPassedHosts.has(domain)) {
421
- // perform XHR-based preflight
422
- const init = {
423
- method: this._requestMethod,
424
- headers: this._capturedRequestHeaders,
425
- credentials: this.withCredentials ? INCLUDE : SAME_ORIGIN,
426
- };
427
- performOptionsPreflightForXHR(url, init, xSf3RidHeaderValue, domain)
428
- .then((preflight) => {
429
- if (preflight === ActionType.IGNORE) {
430
- dynamicExcludedHosts.add(domain);
431
- originalSend.call(this, args);
432
- }
433
- else {
434
- proceedSend();
435
- }
436
- })
437
- .catch(() => {
438
- // On error, treat as ignore
439
- dynamicExcludedHosts.add(domain);
440
- originalSend.call(this, args);
441
- });
442
- // just return void
443
- return;
444
- }
445
- return proceedSend();
561
+ // 6️⃣ On successful load
562
+ this.addEventListener("load", () => {
563
+ const status = this.status || 0;
564
+ if (status >= 200 && status < 300) {
565
+ emitFinished(true, status, "");
566
+ }
567
+ else {
568
+ const msg = this.statusText || `HTTP ${status}`;
569
+ emitFinished(false, status, msg);
570
+ }
571
+ }, { once: true });
572
+ // 7️⃣ On network/CORS error
573
+ this.addEventListener("error", () => {
574
+ // XHR error events often mean CORS or connectivity; status is usually 0
575
+ const status = this.status || 0;
576
+ const msg = status === 0
577
+ ? "Network or CORS failure"
578
+ : this.statusText || `Error ${status}`;
579
+ emitFinished(false, status, msg);
580
+ }, { once: true });
581
+ // 8️⃣ Finally, send the request
582
+ return originalSend.apply(this, args);
446
583
  };
447
584
  }
448
- /**
449
- * Performs an OPTIONS preflight check to decide header propagation.
450
- * Returns 'propagate' if OPTIONS succeeds, 'ignore' otherwise.
451
- */
452
- async function performOptionsPreflight(target, thisArg, url, init, sessionId, domain) {
453
- try {
454
- const headers = new Headers(init.headers || {});
455
- headers.set(xSf3RidHeader, sessionId);
456
- const response = await target.call(thisArg, url, {
457
- method: "OPTIONS",
458
- headers,
459
- });
460
- if (response.ok) {
461
- return ActionType.PROPAGATE;
462
- }
463
- else {
464
- DEBUG &&
465
- console.log(`[Fetch Interceptor] OPTIONS returned status ${response.status} for ${domain}`);
466
- return null;
467
- }
468
- }
469
- catch (error) {
470
- // Treat fetch errors (likely CORS failures) as ignore
471
- if (error instanceof TypeError || error?.message?.includes(CORS_KEYWORD)) {
472
- DEBUG &&
473
- console.log(`[Fetch Interceptor] Preflight OPTIONS CORS error for ${domain}:`, error);
474
- return ActionType.IGNORE;
475
- }
476
- // Other failures also ignored as some APIs or reverse proxies (e.g. NGINX) don’t route
477
- // or handle OPTIONS requests, leading to:
478
- // * 404 Not Found
479
- // * 405 Method Not Allowed
480
- // * 500 Internal Server Error
481
- DEBUG &&
482
- console.log(`[Fetch Interceptor] Preflight OPTIONS failed for ${domain}:`, error);
483
- return null;
484
- }
485
- }
486
585
  // Updated fetch interceptor with exclusion handling
487
586
  function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
488
587
  const originalFetch = window.fetch;
489
588
  const sessionId = getOrSetSessionId();
490
- const cache = new Map();
589
+ const cachedDomains = new Map();
491
590
  window.fetch = new Proxy(originalFetch, {
492
591
  apply: async (target, thisArg, args) => {
493
592
  let input = args[0];
494
593
  let init = args[1] || {};
495
594
  let url;
595
+ // 1️⃣ Normalize URL string
496
596
  if (typeof input === "string") {
497
597
  url = input;
498
598
  }
@@ -503,130 +603,103 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
503
603
  url = input.href;
504
604
  }
505
605
  else {
506
- return target.apply(thisArg, args); // Skip unsupported inputs
606
+ return target.apply(thisArg, args);
507
607
  }
508
- // Determine the target domain
509
608
  const domain = new URL(url, window.location.href).hostname;
510
- // Use cached decision if available
511
- if (cache.has(domain)) {
512
- const decision = cache.get(domain);
513
- if (decision === ActionType.IGNORE) {
514
- return target.apply(thisArg, args);
515
- }
516
- if (decision === ActionType.PROPAGATE) {
517
- return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
518
- }
519
- }
520
- // Check exclusion domains and cache 'ignore'
609
+ // 2️⃣ Skip header injection if excluded
521
610
  if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo)) {
522
- cache.set(domain, ActionType.IGNORE);
523
- return target.apply(thisArg, args);
524
- }
525
- let decision = ActionType.PROPAGATE;
526
- // Check if domain verified before
527
- if (!dynamicPassedHosts.has(domain)) {
528
- // Perform OPTIONS preflight to decide header propagation
529
- const res = await performOptionsPreflight(target, thisArg, url, init, sessionId, domain);
530
- // Skip the header propagation as OPTIONS return Ignore
531
- if (res === ActionType.IGNORE) {
532
- decision = res;
533
- dynamicExcludedHosts.add(domain);
534
- }
535
- }
536
- cache.set(domain, decision);
537
- if (decision === ActionType.PROPAGATE) {
538
- return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
539
- }
540
- else {
541
611
  return target.apply(thisArg, args);
542
612
  }
613
+ // 3️⃣ Delegate to our existing wrapper that injects header,
614
+ // handles retries on BAD_HTTP_STATUS, and updates dynamic sets.
615
+ return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
543
616
  },
544
617
  });
545
- // Wrapper function to emit 'networkRequest' event
618
+ // 2️⃣ Fetch interceptor’s injectHeaderWrapper(); emits 'networkRequest' event
546
619
  async function injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url) {
547
- if (sessionId) {
548
- const networkUUID = uuidv4();
549
- const urlAndStoredUuids = getUrlAndStoredUuids();
550
- const method = init.method || "GET";
551
- const startTime = Date.now();
552
- const domain = new URL(url).hostname;
553
- try {
554
- let response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
555
- let isRetry = false;
556
- // Retry logic for 400/403 before logging finished event
557
- // If the server rejects our header, retry without it
558
- if (BAD_HTTP_STATUS.includes(response.status)) {
559
- DEBUG && console.log("Perform retry as status was fail:", response);
560
- response = retryWithoutPropagateHeaders(target, thisArg, args, url);
561
- isRetry = true;
562
- }
563
- const endTime = Date.now();
564
- const status = response.status;
565
- const success = response.ok;
566
- const error = success ? "" : `Request Error: ${response.statusText}`;
567
- if (success) {
568
- (isRetry ? dynamicExcludedHosts : dynamicPassedHosts).add(domain);
569
- }
570
- // Emit 'networkRequestFinished' event
571
- const eventData = {
572
- type: NetworkRequestEventId,
573
- timestamp: endTime,
574
- sessionId,
575
- data: {
576
- request_id: networkUUID,
577
- session_id: sessionId,
578
- timestamp_start: startTime,
579
- timestamp_end: endTime,
580
- response_code: status,
581
- success,
582
- error,
583
- method,
584
- url,
585
- },
586
- ...urlAndStoredUuids,
587
- };
588
- sendEvent(eventData);
589
- return response;
620
+ if (!sessionId) {
621
+ return target.apply(thisArg, args);
622
+ }
623
+ const urlObj = new URL(url, window.location.href);
624
+ const domain = urlObj.hostname;
625
+ const hostPath = urlObj.pathname === "/" ? domain : `${domain}${urlObj.pathname}`;
626
+ const networkUUID = uuidv4();
627
+ const urlAndStoredUuids = getUrlAndStoredUuids();
628
+ const method = init.method || "GET";
629
+ const startTime = Date.now();
630
+ try {
631
+ let response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
632
+ let isRetry = false;
633
+ // Retry logic for 400/403 before logging finished event
634
+ if (BAD_HTTP_STATUS.includes(response.status)) {
635
+ DEBUG && console.log("Perform retry as status was fail:", response);
636
+ response = await retryWithoutPropagateHeaders(target, thisArg, args, url);
637
+ isRetry = true;
590
638
  }
591
- catch (error) {
592
- const endTime = Date.now();
593
- const success = false;
594
- const responseCode = error.response?.status || 500;
595
- const errorMessage = error.message || "Fetch request failed";
596
- // Treat fetch errors (likely CORS failures) as ignore
597
- // Since some APIs or reverse proxies (such as NGINX) do not route or handle OPTIONS requests, CORS may occur while the request is being made.
598
- if (error instanceof TypeError ||
599
- error?.message?.includes(CORS_KEYWORD)) {
600
- DEBUG &&
601
- console.log(`[Fetch Interceptor] CORS error for ${domain}:`, error);
602
- dynamicExcludedHosts.add(domain);
603
- return target.apply(thisArg, args);
604
- }
639
+ const endTime = Date.now();
640
+ const status = response.status;
641
+ const success = response.ok;
642
+ const error = success ? "" : `Request Error: ${response.statusText}`;
643
+ // 5️⃣ Update dynamic sets
644
+ if (success) {
605
645
  dynamicPassedHosts.add(domain);
606
- // Emit 'networkRequestFinished' event with error
607
- const eventData = {
608
- type: NetworkRequestEventId,
609
- timestamp: endTime,
610
- sessionId,
611
- data: {
612
- request_id: networkUUID,
613
- session_id: sessionId,
614
- timestamp_start: startTime,
615
- timestamp_end: endTime,
616
- response_code: responseCode,
617
- success,
618
- error: errorMessage,
619
- method,
620
- url,
621
- },
622
- ...urlAndStoredUuids,
623
- };
624
- sendEvent(eventData);
625
- throw error;
626
646
  }
647
+ else {
648
+ dynamicExcludedHosts.add(hostPath);
649
+ }
650
+ // Emit 'networkRequestFinished' event
651
+ const eventData = {
652
+ type: NetworkRequestEventId,
653
+ timestamp: endTime,
654
+ sessionId,
655
+ data: {
656
+ request_id: networkUUID,
657
+ session_id: sessionId,
658
+ timestamp_start: startTime,
659
+ timestamp_end: endTime,
660
+ response_code: status,
661
+ success,
662
+ error,
663
+ method,
664
+ url,
665
+ },
666
+ ...urlAndStoredUuids,
667
+ };
668
+ sendEvent(eventData);
669
+ return response;
627
670
  }
628
- else {
629
- return target.apply(thisArg, args);
671
+ catch (error) {
672
+ const endTime = Date.now();
673
+ const success = false;
674
+ const responseCode = error.response?.status || 500;
675
+ const errorMessage = error.message || "Fetch request failed";
676
+ if (error instanceof TypeError &&
677
+ error?.message?.toLowerCase().includes(CORS_KEYWORD.toLowerCase())) {
678
+ // CORS/network failure: exclude just this path
679
+ dynamicExcludedHosts.add(hostPath);
680
+ return target.apply(thisArg, args);
681
+ }
682
+ // On other errors, treat as “passed” so we don’t re-preflight
683
+ dynamicPassedHosts.add(domain);
684
+ const eventData = {
685
+ type: NetworkRequestEventId,
686
+ timestamp: endTime,
687
+ sessionId,
688
+ data: {
689
+ request_id: networkUUID,
690
+ session_id: sessionId,
691
+ timestamp_start: startTime,
692
+ timestamp_end: endTime,
693
+ response_code: responseCode,
694
+ success,
695
+ error: errorMessage,
696
+ method,
697
+ url,
698
+ },
699
+ ...urlAndStoredUuids,
700
+ };
701
+ sendEvent(eventData);
702
+ throw error;
630
703
  }
631
704
  }
632
705
  // Helper function to inject the X-Sf3-Rid header
@@ -654,23 +727,44 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
654
727
  async function retryWithoutPropagateHeaders(target, thisArg, args, url) {
655
728
  const domain = new URL(url).hostname;
656
729
  try {
657
- // Retry the fetch without the header
658
- // const retryResponse = await originalFetch(retryRequest);
659
- const response = target.apply(thisArg, args);
660
- // Check if retry succeeded (no network error thrown, and not a 400/403 response)
661
- if (response.ok || !BAD_HTTP_STATUS.includes(response.status)) {
662
- // Mark this domain to exclude the header going forward without header
663
- dynamicExcludedHosts.add(domain);
664
- cache.set(domain, ActionType.IGNORE); // mark domain as 'ignore' in the cache
665
- // Log the original failure and the successful retry
666
- console.info(`Retried request to ${url} without ${xSf3RidHeader} succeeded. ` +
667
- `Added "${domain}" to header exclusion lists.`);
730
+ // **Fix:** Properly await and clone the request without the tracing header
731
+ let input = args[0];
732
+ let init = args[1] || {};
733
+ if (typeof input === "string" || input instanceof URL) {
734
+ const retryInit = { ...init };
735
+ const retryHeaders = new Headers(init.headers || {});
736
+ retryHeaders.delete(xSf3RidHeader);
737
+ retryInit.headers = retryHeaders;
738
+ const response = await target.call(thisArg, input, retryInit);
739
+ if (response.ok || !BAD_HTTP_STATUS.includes(response.status)) {
740
+ dynamicExcludedHosts.add(domain);
741
+ cachedDomains.set(domain, ActionType.IGNORE);
742
+ DEBUG &&
743
+ console.info(`Retried request to ${url} without ${xSf3RidHeader} succeeded. Added "${domain}" to exclusion list.`);
744
+ }
745
+ return response;
746
+ }
747
+ else if (input instanceof Request) {
748
+ const original = input;
749
+ const cloned = original.clone();
750
+ const retryHeaders = new Headers(cloned.headers);
751
+ retryHeaders.delete(xSf3RidHeader);
752
+ const retryReq = new Request(cloned, { headers: retryHeaders });
753
+ const response = await target.call(thisArg, retryReq, init);
754
+ if (response.ok || !BAD_HTTP_STATUS.includes(response.status)) {
755
+ dynamicExcludedHosts.add(domain);
756
+ cachedDomains.set(domain, ActionType.IGNORE);
757
+ DEBUG &&
758
+ console.info(`Retried request to ${url} without ${xSf3RidHeader} succeeded. Added "${domain}" to exclusion list.`);
759
+ }
760
+ return response;
761
+ }
762
+ else {
763
+ // Fallback
764
+ return target.apply(thisArg, args);
668
765
  }
669
- // Return the response from the retry attempt (successful or not)
670
- return response;
671
766
  }
672
767
  catch (retryError) {
673
- // Propagate the failure (no domain added to exclude lists since retry failed)
674
768
  DEBUG &&
675
769
  console.log(`Retry without ${xSf3RidHeader} for ${url} also failed:`, retryError);
676
770
  throw retryError;
@@ -684,27 +778,29 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
684
778
  initializeConsolePlugin(DEFAULT_CONSOLE_RECORDING_SETTINGS, sessionId);
685
779
  storeCredentialsAndConnection({ apiKey, backendApi });
686
780
  trackDomainChanges();
687
- // Add provided domainsToNotPropagateHeaderTo to dynamicExcludedHosts without triggering updateExcludedHostsStorageAndBackend
688
- domainsToNotPropagateHeaderTo?.forEach((host) => {
689
- host?.trim() && originalExcludedAdd.call(dynamicExcludedHosts, host);
781
+ // ─── Merge stored excludes + passed-in excludes ───
782
+ const initialExcludes = new Set(dynamicExcludedHosts);
783
+ domainsToNotPropagateHeaderTo.forEach((host) => {
784
+ if (host?.trim()) {
785
+ dynamicExcludedHosts.add(host.trim());
786
+ }
690
787
  });
691
- // Persist updated excluded hosts to localStorage
692
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(Array.from(dynamicExcludedHosts)));
693
- // Add provided domainsToPropagateHeaderTo to dynamicPassedHosts
788
+ const newlyExcluded = Array.from(dynamicExcludedHosts).filter((h) => !initialExcludes.has(h));
789
+ if (newlyExcluded.length) {
790
+ // single notify of the full updated list
791
+ updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
792
+ }
793
+ // ─── Merge passed hosts ───
694
794
  domainsToPropagateHeaderTo.forEach((host) => {
695
- originalPassedAdd.call(dynamicPassedHosts, host);
795
+ if (host?.trim()) {
796
+ dynamicPassedHosts.add(host.trim());
797
+ }
696
798
  });
697
- // Persist updated included hosts to localStorage
698
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
699
- // Non-blocking GraphQL request to send the domains if provided
700
- if (dynamicExcludedHosts.size > 0) {
701
- sendDomainsToNotPropagateHeaderTo(apiKey, [...dynamicExcludedHosts, ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT], backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
702
- }
703
799
  sessionStorage.setItem(SF_API_KEY_FOR_UPDATE, apiKey);
704
800
  sessionStorage.setItem(SF_BACKEND_API, backendApi);
705
801
  // Setup interceptors with custom ignore and propagate domains
706
- setupXMLHttpRequestInterceptor(domainsToPropagateHeaderTo);
707
- setupFetchInterceptor(domainsToPropagateHeaderTo);
802
+ setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo);
803
+ setupFetchInterceptor(domainsToNotPropagateHeaderTo);
708
804
  gatherAndCacheDeviceInfo();
709
805
  try {
710
806
  const captureSettingsResponse = await fetchCaptureSettings(apiKey, backendApi);