@sailfish-ai/recorder 1.7.9 → 1.7.12-alpha5

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.
@@ -0,0 +1,42 @@
1
+ import { openDB } from 'idb';
2
+ const DB_NAME = 'leapsEventDB';
3
+ const STORE_NAME = 'recordingEvents';
4
+ const dbPromise = openDB(DB_NAME, 1, {
5
+ upgrade(db) {
6
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
7
+ db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
8
+ }
9
+ },
10
+ });
11
+ export async function saveEventToIDB(event) {
12
+ const db = await dbPromise;
13
+ const tx = db.transaction(STORE_NAME, 'readwrite');
14
+ await tx.store.add({ timestamp: Date.now(), data: event });
15
+ await tx.done;
16
+ }
17
+ export async function saveEventsToIDB(events) {
18
+ const db = await dbPromise;
19
+ const tx = db.transaction(STORE_NAME, 'readwrite');
20
+ for (const event of events) {
21
+ await tx.store.add({ timestamp: Date.now(), data: event });
22
+ }
23
+ await tx.done;
24
+ }
25
+ export async function getAllIndexedEvents() {
26
+ const db = await dbPromise;
27
+ return await db.getAll(STORE_NAME);
28
+ }
29
+ export async function deleteEventById(id) {
30
+ const db = await dbPromise;
31
+ const tx = db.transaction(STORE_NAME, 'readwrite');
32
+ await tx.store.delete(id);
33
+ await tx.done;
34
+ }
35
+ export async function deleteEventsByIds(ids) {
36
+ const db = await dbPromise;
37
+ const tx = db.transaction(STORE_NAME, 'readwrite');
38
+ for (const id of ids) {
39
+ await tx.store.delete(id);
40
+ }
41
+ await tx.done;
42
+ }
package/dist/graphql.js CHANGED
@@ -60,3 +60,30 @@ export function sendDomainsToNotPropagateHeaderTo(apiKey, domains, backendApi) {
60
60
  domainsToNotPassHeaderTo(apiKey: $apiKey, domains: $domains)
61
61
  }`, { apiKey, domains, backendApi });
62
62
  }
63
+ export function createTriageFromRecorder(apiKey, backendApi, recordingSessionId, timestampStart, timestampEnd, description) {
64
+ return sendGraphQLRequest("CreateTriageFromRecorder", `mutation CreateTriageFromRecorder(
65
+ $apiKey: String!,
66
+ $recordingSessionId: String!,
67
+ $timestampStart: String!,
68
+ $timestampEnd: String!,
69
+ $description: String
70
+ ) {
71
+ createTriageFromRecorder(
72
+ apiKey: $apiKey,
73
+ recordingSessionId: $recordingSessionId,
74
+ timestampStart: $timestampStart,
75
+ timestampEnd: $timestampEnd,
76
+ description: $description
77
+ ) {
78
+ id
79
+ }
80
+ }
81
+ `, {
82
+ apiKey,
83
+ recordingSessionId,
84
+ timestampStart,
85
+ timestampEnd,
86
+ description,
87
+ backendApi,
88
+ });
89
+ }
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
+ const DEBUG = import.meta.env.VITE_DEBUG ? import.meta.env.VITE_DEBUG : false;
1
2
  // import { NetworkRecordOptions } from "@sailfish-rrweb/rrweb-plugin-network-record";
2
3
  import { v4 as uuidv4 } from "uuid";
4
+ import { NetworkRequestEventId, xSf3RidHeader } from "./constants";
3
5
  import { gatherAndCacheDeviceInfo } from "./deviceInfo";
4
- import { cacheEvents, sendRecordingEvents } from "./eventCache";
5
6
  import { fetchCaptureSettings, sendDomainsToNotPropagateHeaderTo, startRecordingSession, } from "./graphql";
6
7
  import { sendMapUuidIfAvailable } from "./mapUuid";
7
- import { initializeDomContentEvents, initializeConsolePlugin, initializeRecording, getUrlAndStoredUuids, } from "./recording";
8
- import { sendMessage } from "./websocket";
9
- import { NetworkRequestEventId, xSf3RidHeader } from "./constants";
8
+ import { setupIssueReporting } from "./modal";
9
+ import { getUrlAndStoredUuids, initializeConsolePlugin, initializeDomContentEvents, initializeRecording, } from "./recording";
10
+ import { sendEvent, sendMessage } from "./websocket";
10
11
  // Default list of domains to ignore
11
12
  const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
12
13
  "t.co",
@@ -17,6 +18,84 @@ const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
17
18
  "*.smooch.io", // Exclude smooch-related requests
18
19
  "*.zendesk.com", // Exclude zendesk-related requests
19
20
  ];
21
+ const BAD_HTTP_STATUS = [
22
+ 400, // BAD REQUEST
23
+ 403, // FORBIDDEN
24
+ ];
25
+ const CORS_KEYWORD = "CORS";
26
+ const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
27
+ const DYNAMIC_EXCLUDED_HOSTS_KEY = "dynamicExcludedHosts";
28
+ const SF_API_KEY_FOR_UPDATE = "sailfishApiKey";
29
+ const SF_BACKEND_API = "sailfishBackendApi";
30
+ const INCLUDE = "include";
31
+ const SAME_ORIGIN = "same-origin";
32
+ /**
33
+ * Notify the backend of the updated dynamicExcludedHosts
34
+ */
35
+ function updateExcludedHostsStorageAndBackend(dynamicExcludedHosts) {
36
+ const apiKeyForUpdate = sessionStorage.getItem(SF_API_KEY_FOR_UPDATE) || "";
37
+ const apiForUpdate = sessionStorage.getItem(SF_BACKEND_API) || "";
38
+ if (!apiForUpdate) {
39
+ return;
40
+ }
41
+ 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
+ }
43
+ const dynamicExcludedHosts = new Set();
44
+ const dynamicPassedHosts = new Set();
45
+ // Load initial dynamicExcludedHosts from localStorage
46
+ (() => {
47
+ 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);
55
+ localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
56
+ }
57
+ }
58
+ })();
59
+ // Load initial dynamicPassedHosts from localStorage
60
+ (() => {
61
+ 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);
69
+ localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
70
+ }
71
+ }
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;
79
+ }
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
+ };
95
+ const ActionType = {
96
+ PROPAGATE: "propagate",
97
+ IGNORE: "ignore",
98
+ };
20
99
  export const DEFAULT_CAPTURE_SETTINGS = {
21
100
  recordCanvas: false,
22
101
  recordCrossOriginIframes: false,
@@ -216,19 +295,88 @@ export function matchUrlWithWildcard(url, patterns) {
216
295
  return true;
217
296
  });
218
297
  }
298
+ function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo = []) {
299
+ const combinedIgnoreDomains = [
300
+ ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
301
+ ...domainsToNotPropagateHeadersTo,
302
+ ];
303
+ const defaultExcluded = matchUrlWithWildcard(url, combinedIgnoreDomains);
304
+ if (defaultExcluded) {
305
+ return true;
306
+ }
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
+ });
357
+ }
219
358
  // Updated XMLHttpRequest interceptor with domain exclusion
220
- function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo, domainsToPropagateHeadersTo = []) {
359
+ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
221
360
  const originalOpen = XMLHttpRequest.prototype.open;
222
361
  const originalSend = XMLHttpRequest.prototype.send;
362
+ const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
223
363
  const sessionId = getOrSetSessionId();
224
- // Combined ignore and propagate logic
225
- const combinedIgnoreDomains = [
226
- ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
227
- ...domainsToNotPropagateHeaderTo,
228
- ];
364
+ // Intercept setRequestHeader()
365
+ XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
366
+ // initialize the buffer on first use
367
+ if (!this._capturedRequestHeaders) {
368
+ this._capturedRequestHeaders = {};
369
+ }
370
+ // store header name + value
371
+ this._capturedRequestHeaders[name] = value;
372
+ // still call the native method so the header actually goes on the wire
373
+ return originalSetRequestHeader.call(this, name, value);
374
+ };
229
375
  // Intercept open()
230
376
  XMLHttpRequest.prototype.open = function (method, url, ...args) {
231
377
  this._requestUrl = typeof url === "string" && url.length > 0 ? url : null;
378
+ this._requestMethod = method;
379
+ this._capturedRequestHeaders = {};
232
380
  return originalOpen.apply(this, [method, url, ...args]);
233
381
  };
234
382
  // Intercept send()
@@ -236,37 +384,112 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo, domainsTo
236
384
  const url = this._requestUrl;
237
385
  if (!url)
238
386
  return originalSend.apply(this, args);
387
+ const domain = new URL(url).hostname;
239
388
  // Skip domain check for excluded domains
240
- if (matchUrlWithWildcard(url, combinedIgnoreDomains)) {
389
+ if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo)) {
241
390
  return originalSend.apply(this, args);
242
391
  }
243
- // Check if domain should propagate headers
244
- const shouldPropagateHeader = domainsToPropagateHeadersTo.length === 0 ||
245
- matchUrlWithWildcard(url, domainsToPropagateHeadersTo);
246
- if (sessionId && shouldPropagateHeader) {
247
- try {
248
- const pageVisitUUID = sessionStorage.getItem("pageVisitUUID");
249
- const networkUUID = uuidv4();
250
- this.setRequestHeader(xSf3RidHeader, `${sessionId}/${pageVisitUUID}/${networkUUID}`);
251
- }
252
- catch (e) {
253
- console.warn(`Could not set X-Sf3-Rid header for ${url}`, e);
392
+ const pageVisitUUID = sessionStorage.getItem("pageVisitUUID");
393
+ 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
+ }
254
403
  }
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
+ };
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;
255
444
  }
256
- return originalSend.apply(this, args);
445
+ return proceedSend();
257
446
  };
258
447
  }
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
+ }
259
486
  // Updated fetch interceptor with exclusion handling
260
- function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagateHeadersTo = []) {
487
+ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
261
488
  const originalFetch = window.fetch;
262
489
  const sessionId = getOrSetSessionId();
263
- const combinedIgnoreDomains = [
264
- ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
265
- ...domainsToNotPropagateHeadersTo,
266
- ];
267
490
  const cache = new Map();
268
491
  window.fetch = new Proxy(originalFetch, {
269
- apply: (target, thisArg, args) => {
492
+ apply: async (target, thisArg, args) => {
270
493
  let input = args[0];
271
494
  let init = args[1] || {};
272
495
  let url;
@@ -282,30 +505,41 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagat
282
505
  else {
283
506
  return target.apply(thisArg, args); // Skip unsupported inputs
284
507
  }
285
- // Cache check
286
- if (cache.has(url)) {
287
- const cachedResult = cache.get(url);
288
- if (cachedResult === "ignore") {
508
+ // Determine the target domain
509
+ 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) {
289
514
  return target.apply(thisArg, args);
290
515
  }
291
- if (cachedResult === "propagate") {
516
+ if (decision === ActionType.PROPAGATE) {
292
517
  return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
293
518
  }
294
519
  }
295
- // Check domain exclusion
296
- if (matchUrlWithWildcard(url, combinedIgnoreDomains)) {
297
- cache.set(url, "ignore");
520
+ // Check exclusion domains and cache 'ignore'
521
+ if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo)) {
522
+ cache.set(domain, ActionType.IGNORE);
298
523
  return target.apply(thisArg, args);
299
524
  }
300
- // Check domain propagation
301
- const shouldPropagateHeader = domainsToPropagateHeadersTo.length === 0 ||
302
- matchUrlWithWildcard(url, domainsToPropagateHeadersTo);
303
- if (!shouldPropagateHeader) {
304
- cache.set(url, "ignore");
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 {
305
541
  return target.apply(thisArg, args);
306
542
  }
307
- cache.set(url, "propagate");
308
- return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
309
543
  },
310
544
  });
311
545
  // Wrapper function to emit 'networkRequest' event
@@ -315,16 +549,29 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagat
315
549
  const urlAndStoredUuids = getUrlAndStoredUuids();
316
550
  const method = init.method || "GET";
317
551
  const startTime = Date.now();
552
+ const domain = new URL(url).hostname;
318
553
  try {
319
- const response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
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
+ }
320
563
  const endTime = Date.now();
321
564
  const status = response.status;
322
565
  const success = response.ok;
323
- const error = success ? '' : `Request Error: ${response.statusText}`;
566
+ const error = success ? "" : `Request Error: ${response.statusText}`;
567
+ if (success) {
568
+ (isRetry ? dynamicExcludedHosts : dynamicPassedHosts).add(domain);
569
+ }
324
570
  // Emit 'networkRequestFinished' event
325
- cacheEvents({
571
+ const eventData = {
326
572
  type: NetworkRequestEventId,
327
573
  timestamp: endTime,
574
+ sessionId,
328
575
  data: {
329
576
  request_id: networkUUID,
330
577
  session_id: sessionId,
@@ -337,7 +584,8 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagat
337
584
  url,
338
585
  },
339
586
  ...urlAndStoredUuids,
340
- });
587
+ };
588
+ sendEvent(eventData);
341
589
  return response;
342
590
  }
343
591
  catch (error) {
@@ -345,10 +593,21 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagat
345
593
  const success = false;
346
594
  const responseCode = error.response?.status || 500;
347
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
+ }
605
+ dynamicPassedHosts.add(domain);
348
606
  // Emit 'networkRequestFinished' event with error
349
- cacheEvents({
607
+ const eventData = {
350
608
  type: NetworkRequestEventId,
351
609
  timestamp: endTime,
610
+ sessionId,
352
611
  data: {
353
612
  request_id: networkUUID,
354
613
  session_id: sessionId,
@@ -360,8 +619,9 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagat
360
619
  method,
361
620
  url,
362
621
  },
363
- ...urlAndStoredUuids
364
- });
622
+ ...urlAndStoredUuids,
623
+ };
624
+ sendEvent(eventData);
365
625
  throw error;
366
626
  }
367
627
  }
@@ -390,21 +650,61 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo, domainsToPropagat
390
650
  return await target.call(thisArg, input, modifiedInit);
391
651
  }
392
652
  }
653
+ // Helper to retry a fetch without the X-Sf3-Rid header if the initial attempt fails due to that header
654
+ async function retryWithoutPropagateHeaders(target, thisArg, args, url) {
655
+ const domain = new URL(url).hostname;
656
+ 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.`);
668
+ }
669
+ // Return the response from the retry attempt (successful or not)
670
+ return response;
671
+ }
672
+ catch (retryError) {
673
+ // Propagate the failure (no domain added to exclude lists since retry failed)
674
+ DEBUG &&
675
+ console.log(`Retry without ${xSf3RidHeader} for ${url} also failed:`, retryError);
676
+ throw retryError;
677
+ }
678
+ }
393
679
  }
394
680
  // Main Recording Function
395
681
  export async function startRecording({ apiKey, backendApi = "https://api-service.sailfishqa.com", domainsToPropagateHeaderTo = [], domainsToNotPropagateHeaderTo = [], serviceVersion = "", serviceIdentifier = "", }) {
396
- initializeDomContentEvents();
397
- initializeConsolePlugin(DEFAULT_CONSOLE_RECORDING_SETTINGS);
398
682
  let sessionId = getOrSetSessionId();
683
+ initializeDomContentEvents(sessionId);
684
+ initializeConsolePlugin(DEFAULT_CONSOLE_RECORDING_SETTINGS, sessionId);
399
685
  storeCredentialsAndConnection({ apiKey, backendApi });
400
686
  trackDomainChanges();
687
+ // Add provided domainsToNotPropagateHeaderTo to dynamicExcludedHosts without triggering updateExcludedHostsStorageAndBackend
688
+ domainsToNotPropagateHeaderTo?.forEach((host) => {
689
+ host?.trim() && originalExcludedAdd.call(dynamicExcludedHosts, host);
690
+ });
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
694
+ domainsToPropagateHeaderTo.forEach((host) => {
695
+ originalPassedAdd.call(dynamicPassedHosts, host);
696
+ });
697
+ // Persist updated included hosts to localStorage
698
+ localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
401
699
  // Non-blocking GraphQL request to send the domains if provided
402
- if (domainsToNotPropagateHeaderTo.length > 0) {
403
- sendDomainsToNotPropagateHeaderTo(apiKey, domainsToNotPropagateHeaderTo, backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
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));
404
702
  }
703
+ sessionStorage.setItem(SF_API_KEY_FOR_UPDATE, apiKey);
704
+ sessionStorage.setItem(SF_BACKEND_API, backendApi);
405
705
  // Setup interceptors with custom ignore and propagate domains
406
- setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo, domainsToPropagateHeaderTo);
407
- setupFetchInterceptor(domainsToNotPropagateHeaderTo, domainsToPropagateHeaderTo);
706
+ setupXMLHttpRequestInterceptor(domainsToPropagateHeaderTo);
707
+ setupFetchInterceptor(domainsToPropagateHeaderTo);
408
708
  gatherAndCacheDeviceInfo();
409
709
  try {
410
710
  const captureSettingsResponse = await fetchCaptureSettings(apiKey, backendApi);
@@ -417,7 +717,6 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
417
717
  backendApi, apiKey, sessionId);
418
718
  // Send parameters once before starting interval
419
719
  sendMapUuidIfAvailable(serviceIdentifier, serviceVersion);
420
- setInterval(() => sendRecordingEvents(websocket), 10000);
421
720
  }
422
721
  else {
423
722
  console.error("Failed to start recording session:", sessionResponse.errors || sessionResponse);
@@ -433,12 +732,20 @@ export const initRecorder = async (options) => {
433
732
  return;
434
733
  }
435
734
  // Directly invoke the startRecording function from within the same package
436
- return startRecording(options);
735
+ return startRecording(options).then(() => {
736
+ setupIssueReporting({
737
+ apiKey: options.apiKey,
738
+ backendApi: options.backendApi ?? "https://api-service.sailfishqa.com",
739
+ getSessionId: () => getOrSetSessionId(),
740
+ enableShortcuts: options.enableShortcuts ?? false,
741
+ });
742
+ });
437
743
  };
438
744
  // Re-export from other modules
439
- export * from "./eventCache";
440
745
  export * from "./graphql";
746
+ export { openReportIssueModal } from "./modal";
441
747
  export * from "./recording";
442
748
  export * from "./sendSailfishMessages";
443
749
  export * from "./types";
750
+ export * from "./utils";
444
751
  export * from "./websocket";