@sailfish-ai/recorder 1.7.21 → 1.7.23

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";
@@ -25,15 +25,106 @@ const BAD_HTTP_STATUS = [
25
25
  403, // FORBIDDEN
26
26
  ];
27
27
  const CORS_KEYWORD = "CORS";
28
- const STORAGE_VERSION = 1;
29
- const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
30
- 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";
31
31
  const SF_API_KEY_FOR_UPDATE = "sailfishApiKey";
32
32
  const SF_BACKEND_API = "sailfishBackendApi";
33
33
  const INCLUDE = "include";
34
34
  const SAME_ORIGIN = "same-origin";
35
35
  const ALLOWED_HEADERS_HEADER = "access-control-allow-headers";
36
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
+ };
37
128
  /**
38
129
  * Notify the backend of the updated dynamicExcludedHosts
39
130
  */
@@ -45,8 +136,59 @@ function updateExcludedHostsStorageAndBackend(dynamicExcludedHosts) {
45
136
  }
46
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));
47
138
  }
48
- const dynamicExcludedHosts = new Set();
49
- const dynamicPassedHosts = new Set();
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
+ }
50
192
  // 2️⃣ Load & evict old entries (>7 days) + version check for Excluded
51
193
  (() => {
52
194
  const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
@@ -102,53 +244,6 @@ const dynamicPassedHosts = new Set();
102
244
  localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
103
245
  }
104
246
  })();
105
- // Override add() to persist updates to localStorage
106
- const originalExcludedAdd = dynamicExcludedHosts.add;
107
- dynamicExcludedHosts.add = (host) => {
108
- const cleanedHost = host?.trim();
109
- if (!cleanedHost || dynamicExcludedHosts.has(cleanedHost)) {
110
- return dynamicExcludedHosts;
111
- }
112
- originalExcludedAdd.call(dynamicExcludedHosts, cleanedHost);
113
- try {
114
- // read existing map or start fresh
115
- const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
116
- const obj = stored ? JSON.parse(stored) : {};
117
- obj[cleanedHost] = Date.now();
118
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(obj));
119
- }
120
- catch (e) {
121
- if (DEBUG)
122
- console.warn("Persist dynamicExcludedHosts failed:", e);
123
- }
124
- updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
125
- return dynamicExcludedHosts;
126
- };
127
- const originalPassedAdd = dynamicPassedHosts.add;
128
- // === UPDATED: override to store timestamp on add (passed hosts) ===
129
- dynamicPassedHosts.add = (host) => {
130
- const cleanedHost = host?.trim();
131
- if (!cleanedHost) {
132
- return dynamicPassedHosts;
133
- }
134
- // If we already have it, just return
135
- if (dynamicPassedHosts.has(cleanedHost)) {
136
- return dynamicPassedHosts;
137
- }
138
- originalPassedAdd.call(dynamicPassedHosts, cleanedHost);
139
- // Persist a host->timestamp map
140
- try {
141
- const stored = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
142
- const obj = stored ? JSON.parse(stored) : {};
143
- obj[cleanedHost] = Date.now();
144
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(obj));
145
- }
146
- catch (e) {
147
- if (DEBUG)
148
- console.warn("Persist dynamicPassedHosts failed:", e);
149
- }
150
- return dynamicPassedHosts;
151
- };
152
247
  const ActionType = {
153
248
  PROPAGATE: "propagate",
154
249
  IGNORE: "ignore",
@@ -355,75 +450,29 @@ export function matchUrlWithWildcard(url, patterns) {
355
450
  return true;
356
451
  });
357
452
  }
358
- function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo = []) {
359
- 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 = [
360
464
  ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
361
- ...domainsToNotPropagateHeadersTo,
465
+ ...domainsToNotPropagateHeaderTo,
362
466
  ];
363
- const defaultExcluded = matchUrlWithWildcard(url, combinedIgnoreDomains);
364
- if (defaultExcluded) {
467
+ if (matchUrlWithWildcard(url, combinedPatterns)) {
365
468
  return true;
366
469
  }
367
- const domain = new URL(url).hostname;
368
- // Check dynamically excluded hosts (those that reject the tracing header runtime)
369
- return dynamicExcludedHosts.has(domain);
370
- }
371
- /**
372
- * Performs an OPTIONS preflight check using XHR.
373
- * Returns ActionType.PROPAGATE if server responds 2xx, ActionType.IGNORE on error or non-2xx.
374
- */
375
- function performOptionsPreflightForXHR(url, init, xSf3RidHeaderValue, domain) {
376
- return new Promise((resolve) => {
377
- const xhr = new XMLHttpRequest();
378
- xhr.open(OPTIONS, url, true);
379
- xhr.withCredentials = init.credentials === INCLUDE;
380
- // Normalize headers into a standard Headers instance
381
- const headerEntries = new Headers(init.headers || {});
382
- const rawKeys = Array.from(headerEntries.keys());
383
- const customHeaders = rawKeys
384
- .map((h) => h.toLowerCase())
385
- .filter((h) => ![
386
- "accept",
387
- "content-type",
388
- "accept-language",
389
- "content-language",
390
- ].includes(h));
391
- if (customHeaders.length) {
392
- xhr.setRequestHeader("Access-Control-Request-Headers", customHeaders.join(","));
393
- }
394
- // Set the CORS preflight method header
395
- xhr.setRequestHeader("Access-Control-Request-Method", (init.method || "GET").toUpperCase());
396
- // Correctly add the tracing header to the XHR instance
397
- xhr.setRequestHeader(xSf3RidHeader, xSf3RidHeaderValue);
398
- xhr.onload = () => {
399
- if (xhr.status >= 200 && xhr.status < 300) {
400
- const allowed = xhr.getResponseHeader(ALLOWED_HEADERS_HEADER);
401
- if (allowed &&
402
- allowed
403
- .split(",")
404
- .map((h) => h.trim().toLowerCase())
405
- .includes(xSf3RidHeader.toLowerCase())) {
406
- resolve(ActionType.PROPAGATE);
407
- }
408
- else {
409
- DEBUG &&
410
- console.log(`[XHR Interceptor] Header ${xSf3RidHeader} not allowed by preflight for ${domain}`);
411
- resolve(ActionType.IGNORE);
412
- }
413
- }
414
- else {
415
- DEBUG &&
416
- console.log(`[XHR Interceptor] OPTIONS returned status ${xhr.status} for ${domain}`);
417
- resolve(null);
418
- }
419
- };
420
- xhr.onerror = () => {
421
- DEBUG &&
422
- console.log(`[XHR Interceptor] Preflight OPTIONS CORS or network error for ${domain}`);
423
- resolve(ActionType.IGNORE);
424
- };
425
- xhr.send();
426
- });
470
+ // 3️⃣ DYNAMIC-FAILURE EXCLUSION (exact host)
471
+ const hostname = urlObj.hostname;
472
+ if (dynamicExcludedHosts.has(hostname)) {
473
+ return true;
474
+ }
475
+ return false;
427
476
  }
428
477
  // Updated XMLHttpRequest interceptor with domain exclusion
429
478
  function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
@@ -449,124 +498,90 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
449
498
  this._capturedRequestHeaders = {};
450
499
  return originalOpen.apply(this, [method, url, ...args]);
451
500
  };
452
- // Intercept send()
501
+ // 1️⃣ XHR interceptor send()
453
502
  XMLHttpRequest.prototype.send = function (...args) {
454
503
  const url = this._requestUrl;
455
- if (!url)
504
+ if (!url) {
456
505
  return originalSend.apply(this, args);
457
- const domain = new URL(url).hostname;
458
- // 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
459
512
  if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo)) {
460
513
  return originalSend.apply(this, args);
461
514
  }
515
+ // 2️⃣ Prepare header and IDs
462
516
  const pageVisitUUID = sessionStorage.getItem("pageVisitUUID");
463
517
  const networkUUID = uuidv4();
464
- const xSf3RidHeaderValue = `${sessionId}/${pageVisitUUID}/${networkUUID}`;
465
- const proceedSend = () => {
466
- if (sessionId) {
467
- try {
468
- this.setRequestHeader(xSf3RidHeader, xSf3RidHeaderValue);
469
- }
470
- catch (e) {
471
- console.warn(`Could not set X-Sf3-Rid header for ${url}`, e);
472
- }
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);
473
559
  }
474
- // On CORS or network error during send, log it and add to excluded-hosts
475
- this.addEventListener("error", (evt) => {
476
- console.error(`[XHR Interceptor] Network error for ${domain} (${url}):`, evt, `status=${this.status}`, `statusText=${this.statusText}`);
477
- dynamicExcludedHosts.add(domain);
478
- }, { once: true });
479
- // On load, log non-2xx statuses (including CORS-blocked status=0) and track passes
480
- this.addEventListener("load", () => {
481
- if (this.status === 0) {
482
- DEBUG &&
483
- console.error(`[XHR Interceptor] CORS blocked (status=0) for ${domain} (${url})`, `statusText=${this.statusText}`);
484
- dynamicExcludedHosts.add(domain);
485
- }
486
- else if (this.status >= 200 && this.status < 300) {
487
- dynamicPassedHosts.add(domain);
488
- }
489
- else {
490
- DEBUG &&
491
- console.error(`[XHR Interceptor] HTTP error ${this.status} ${this.statusText} for ${domain} (${url})`);
492
- dynamicExcludedHosts.add(domain);
493
- }
494
- }, { once: true });
495
- return originalSend.apply(this, args);
496
560
  };
497
- if (!dynamicPassedHosts.has(domain)) {
498
- // perform XHR-based preflight
499
- const init = {
500
- method: this._requestMethod,
501
- headers: this._capturedRequestHeaders,
502
- credentials: this.withCredentials ? INCLUDE : SAME_ORIGIN,
503
- };
504
- performOptionsPreflightForXHR(url, init, xSf3RidHeaderValue, domain)
505
- .then((preflight) => {
506
- if (preflight === ActionType.IGNORE) {
507
- dynamicExcludedHosts.add(domain);
508
- originalSend.call(this, args);
509
- }
510
- else {
511
- proceedSend();
512
- }
513
- })
514
- .catch(() => {
515
- // On error, treat as ignore
516
- dynamicExcludedHosts.add(domain);
517
- originalSend.call(this, args);
518
- });
519
- // just return void
520
- return;
521
- }
522
- 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);
523
583
  };
524
584
  }
525
- /**
526
- * Performs an OPTIONS preflight check to decide header propagation.
527
- * Returns PROPAGATE if both header & method are allowed, IGNORE if disallowed
528
- * or on a CORS error, null if OPTIONS returned non-2xx.
529
- */
530
- async function performOptionsPreflight(target, thisArg, url, init, sessionId, domain) {
531
- try {
532
- const headers = new Headers(init.headers || {});
533
- headers.set(xSf3RidHeader, sessionId);
534
- const opts = {
535
- method: OPTIONS,
536
- headers,
537
- mode: "cors",
538
- credentials: init.credentials || SAME_ORIGIN,
539
- };
540
- const response = await target.call(thisArg, url, opts);
541
- if (!response.ok) {
542
- DEBUG &&
543
- console.log(`[Fetch Interceptor] OPTIONS returned status ${response.status} for ${domain}`);
544
- return null;
545
- }
546
- // 1️⃣ Check that our header is allowed
547
- const allowedHeaders = response.headers
548
- .get(ALLOWED_HEADERS_HEADER)
549
- ?.split(",")
550
- .map((h) => h.trim().toLowerCase()) || [];
551
- if (!allowedHeaders.includes(xSf3RidHeader.toLowerCase())) {
552
- DEBUG &&
553
- console.log(`[Fetch Interceptor] Header ${xSf3RidHeader} not allowed by preflight for ${domain}`);
554
- return ActionType.IGNORE;
555
- }
556
- return ActionType.PROPAGATE;
557
- }
558
- catch (error) {
559
- if (error instanceof TypeError &&
560
- error.message.toLowerCase().includes(CORS_KEYWORD.toLowerCase())) {
561
- DEBUG &&
562
- console.log(`[Fetch Interceptor] Preflight OPTIONS CORS error for ${domain}:`, error);
563
- return ActionType.IGNORE;
564
- }
565
- DEBUG &&
566
- console.log(`[Fetch Interceptor] Preflight OPTIONS failed for ${domain}:`, error);
567
- return null;
568
- }
569
- }
570
585
  // Updated fetch interceptor with exclusion handling
571
586
  function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
572
587
  const originalFetch = window.fetch;
@@ -577,6 +592,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
577
592
  let input = args[0];
578
593
  let init = args[1] || {};
579
594
  let url;
595
+ // 1️⃣ Normalize URL string
580
596
  if (typeof input === "string") {
581
597
  url = input;
582
598
  }
@@ -587,130 +603,103 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
587
603
  url = input.href;
588
604
  }
589
605
  else {
590
- return target.apply(thisArg, args); // Skip unsupported inputs
606
+ return target.apply(thisArg, args);
591
607
  }
592
- // Determine the target domain
593
608
  const domain = new URL(url, window.location.href).hostname;
594
- // Use cached decision if available
595
- if (cachedDomains.has(domain)) {
596
- const decision = cachedDomains.get(domain);
597
- if (decision === ActionType.IGNORE) {
598
- return target.apply(thisArg, args);
599
- }
600
- if (decision === ActionType.PROPAGATE) {
601
- return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
602
- }
603
- }
604
- // Check exclusion domains and cache 'ignore'
609
+ // 2️⃣ Skip header injection if excluded
605
610
  if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo)) {
606
- cachedDomains.set(domain, ActionType.IGNORE);
607
- return target.apply(thisArg, args);
608
- }
609
- let decision = ActionType.PROPAGATE;
610
- // Check if domain verified before
611
- if (!dynamicPassedHosts.has(domain)) {
612
- // Perform OPTIONS preflight to decide header propagation
613
- const res = await performOptionsPreflight(target, thisArg, url, init, sessionId, domain);
614
- // Skip the header propagation as OPTIONS return Ignore
615
- if (res === ActionType.IGNORE) {
616
- decision = res;
617
- dynamicExcludedHosts.add(domain);
618
- }
619
- }
620
- cachedDomains.set(domain, decision);
621
- if (decision === ActionType.PROPAGATE) {
622
- return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
623
- }
624
- else {
625
611
  return target.apply(thisArg, args);
626
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);
627
616
  },
628
617
  });
629
- // Wrapper function to emit 'networkRequest' event
618
+ // 2️⃣ Fetch interceptor’s injectHeaderWrapper(); emits 'networkRequest' event
630
619
  async function injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url) {
631
- if (sessionId) {
632
- const networkUUID = uuidv4();
633
- const urlAndStoredUuids = getUrlAndStoredUuids();
634
- const method = init.method || "GET";
635
- const startTime = Date.now();
636
- const domain = new URL(url).hostname;
637
- try {
638
- let response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
639
- let isRetry = false;
640
- // Retry logic for 400/403 before logging finished event
641
- // If the server rejects our header, retry without it
642
- if (BAD_HTTP_STATUS.includes(response.status)) {
643
- DEBUG && console.log("Perform retry as status was fail:", response);
644
- response = retryWithoutPropagateHeaders(target, thisArg, args, url);
645
- isRetry = true;
646
- }
647
- const endTime = Date.now();
648
- const status = response.status;
649
- const success = response.ok;
650
- const error = success ? "" : `Request Error: ${response.statusText}`;
651
- if (success) {
652
- (isRetry ? dynamicExcludedHosts : dynamicPassedHosts).add(domain);
653
- }
654
- // Emit 'networkRequestFinished' event
655
- const eventData = {
656
- type: NetworkRequestEventId,
657
- timestamp: endTime,
658
- sessionId,
659
- data: {
660
- request_id: networkUUID,
661
- session_id: sessionId,
662
- timestamp_start: startTime,
663
- timestamp_end: endTime,
664
- response_code: status,
665
- success,
666
- error,
667
- method,
668
- url,
669
- },
670
- ...urlAndStoredUuids,
671
- };
672
- sendEvent(eventData);
673
- 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;
674
638
  }
675
- catch (error) {
676
- const endTime = Date.now();
677
- const success = false;
678
- const responseCode = error.response?.status || 500;
679
- const errorMessage = error.message || "Fetch request failed";
680
- // Treat fetch errors (likely CORS failures) as ignore
681
- // 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.
682
- if (error instanceof TypeError &&
683
- error?.message?.toLowerCase().includes(CORS_KEYWORD.toLowerCase())) {
684
- DEBUG &&
685
- console.log(`[Fetch Interceptor] CORS error for ${domain}:`, error);
686
- dynamicExcludedHosts.add(domain);
687
- return target.apply(thisArg, args);
688
- }
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) {
689
645
  dynamicPassedHosts.add(domain);
690
- // Emit 'networkRequestFinished' event with error
691
- const eventData = {
692
- type: NetworkRequestEventId,
693
- timestamp: endTime,
694
- sessionId,
695
- data: {
696
- request_id: networkUUID,
697
- session_id: sessionId,
698
- timestamp_start: startTime,
699
- timestamp_end: endTime,
700
- response_code: responseCode,
701
- success,
702
- error: errorMessage,
703
- method,
704
- url,
705
- },
706
- ...urlAndStoredUuids,
707
- };
708
- sendEvent(eventData);
709
- throw error;
710
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;
711
670
  }
712
- else {
713
- 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;
714
703
  }
715
704
  }
716
705
  // Helper function to inject the X-Sf3-Rid header
@@ -789,28 +778,24 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
789
778
  initializeConsolePlugin(DEFAULT_CONSOLE_RECORDING_SETTINGS, sessionId);
790
779
  storeCredentialsAndConnection({ apiKey, backendApi });
791
780
  trackDomainChanges();
792
- // Add provided domainsToNotPropagateHeaderTo to dynamicExcludedHosts without triggering updateExcludedHostsStorageAndBackend
793
- domainsToNotPropagateHeaderTo?.forEach((host) => {
794
- 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
+ }
795
787
  });
796
- // Persist updated excluded hosts to localStorage
797
- try {
798
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(Array.from(dynamicExcludedHosts)));
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);
799
792
  }
800
- catch { }
801
- // Add provided domainsToPropagateHeaderTo to dynamicPassedHosts
793
+ // ─── Merge passed hosts ───
802
794
  domainsToPropagateHeaderTo.forEach((host) => {
803
- originalPassedAdd.call(dynamicPassedHosts, host);
795
+ if (host?.trim()) {
796
+ dynamicPassedHosts.add(host.trim());
797
+ }
804
798
  });
805
- // Persist updated included hosts to localStorage
806
- try {
807
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
808
- }
809
- catch { }
810
- // Non-blocking GraphQL request to send the domains if provided
811
- if (dynamicExcludedHosts.size > 0) {
812
- 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));
813
- }
814
799
  sessionStorage.setItem(SF_API_KEY_FOR_UPDATE, apiKey);
815
800
  sessionStorage.setItem(SF_BACKEND_API, backendApi);
816
801
  // Setup interceptors with custom ignore and propagate domains