@sailfish-ai/recorder 1.8.1 → 1.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
1
  # JS/TS Record-Only Package
2
2
 
3
3
  ## Sailfish AI's frontend recorder
4
+
5
+ ## Trigger build - 31112025 1150PM GMT+4
6
+
7
+ ## Trigger build - 06112025 0854PM GMT+4
@@ -1,73 +1,89 @@
1
1
  import { SourceMapConsumer } from "source-map-js";
2
2
  import { sendMessage } from "./websocket";
3
- /**
4
- * Resolves stack traces using source maps.
5
- * @param stackTrace - The minified stack trace.
6
- * @returns The mapped stack trace with original file/line/column.
7
- */
3
+ const mapCache = new Map();
4
+ // Matches: url-or-path/to/file.js?cache=123:LINE:COL (also if wrapped in "foo (...)" )
5
+ const FLEX_RE = /(?:\(|\s|^)(https?:\/\/[^)\s]+|\/[^)\s]+|[^)\s]+)?\/?([^/]+\.js)(?:\?[^:)]*)?:(\d+):(\d+)/;
6
+ async function getConsumerFor(fullPathOrUrl, fileBase) {
7
+ // Build a list of candidate .map URLs (strip query, try same-dir first, then /assets)
8
+ const baseNoQuery = (fullPathOrUrl || `/assets/${fileBase}`).split("?")[0];
9
+ const candidates = [
10
+ `${baseNoQuery}.map`,
11
+ // Vite deps fallback:
12
+ baseNoQuery.replace(/\.js$/, ".js.map"),
13
+ // Legacy assets fallback:
14
+ `/assets/${fileBase}.map`,
15
+ ];
16
+ for (const url of candidates) {
17
+ try {
18
+ if (mapCache.has(url))
19
+ return mapCache.get(url);
20
+ const res = await fetch(url);
21
+ if (!res.ok)
22
+ continue;
23
+ const raw = (await res.json());
24
+ if (!raw || !raw.mappings || !raw.sources)
25
+ continue;
26
+ const consumer = await new SourceMapConsumer(raw);
27
+ mapCache.set(url, consumer);
28
+ return consumer;
29
+ }
30
+ catch {
31
+ // try next candidate
32
+ }
33
+ }
34
+ return null;
35
+ }
8
36
  export async function resolveStackTrace(stackTrace) {
9
37
  if (!stackTrace)
10
38
  return ["No stack trace available"];
11
39
  const traceLines = Array.isArray(stackTrace)
12
40
  ? stackTrace
13
41
  : stackTrace.split("\n");
14
- const mappedStack = [];
15
- for (const line of traceLines) {
16
- const match = line.match(/\/assets\/([^\/]+\.js):(\d+):(\d+)/);
17
- if (!match) {
18
- mappedStack.push(line); // Keep non-matching lines
42
+ const out = [];
43
+ for (const rawLine of traceLines) {
44
+ const m = rawLine.match(FLEX_RE);
45
+ if (!m) {
46
+ out.push(rawLine);
19
47
  continue;
20
48
  }
21
- const [, fileUrl, lineNum, colNum] = match;
22
- const fileName = fileUrl.split("/").pop();
23
- const sourceMapUrl = `/assets/${fileName}.map`;
24
- try {
25
- const response = await fetch(sourceMapUrl);
26
- if (!response.ok) {
27
- break;
28
- }
29
- const rawSourceMap = (await response.json());
30
- if (!rawSourceMap.sources || !Array.isArray(rawSourceMap.sources)) {
31
- mappedStack.push(line);
32
- continue;
33
- }
34
- const consumer = await new SourceMapConsumer(rawSourceMap);
35
- const original = consumer.originalPositionFor({
36
- line: parseInt(lineNum, 10) - 1,
37
- column: parseInt(colNum, 10),
38
- });
39
- if (original.source && original.line) {
40
- // Get index of original source in rawSourceMap.sources
41
- const sourceIndex = rawSourceMap.sources.indexOf(original.source);
42
- let contextSnippet = [];
43
- if (sourceIndex !== -1 &&
44
- rawSourceMap.sourcesContent &&
45
- rawSourceMap.sourcesContent[sourceIndex]) {
46
- const lines = rawSourceMap.sourcesContent[sourceIndex].split("\n");
47
- const errorLine = original.line || 1;
48
- // Define range (clamp within file bounds)
49
- const start = Math.max(errorLine - 6, 0); // -6 because index is 0-based
50
- const end = Math.min(errorLine + 4, lines.length); // +4 to get 5 lines after
51
- // Extract and trim context lines
52
- contextSnippet = lines.slice(start, end).map((line, i) => {
53
- const lineNumber = start + i + 1;
54
- const prefix = lineNumber === errorLine ? "👉" : " ";
55
- return `${prefix} ${lineNumber
56
- .toString()
57
- .padStart(4)} | ${line.trim()}`;
58
- });
59
- }
60
- mappedStack.push(`${original.source}:${original.line}:${original.column} (${original.name || "anonymous"})`);
61
- }
62
- else {
63
- mappedStack.push(line);
49
+ const [, fullPathOrUrl, fileBase, lineStr, colStr] = m;
50
+ const genLine = parseInt(lineStr, 10); // 1-based ✅
51
+ const genCol = Math.max(0, parseInt(colStr, 10) - 1); // 0-based ✅
52
+ if (!Number.isFinite(genLine) || !Number.isFinite(genCol)) {
53
+ out.push(rawLine + " [Invalid line/column]");
54
+ continue;
55
+ }
56
+ const consumer = await getConsumerFor(fullPathOrUrl, fileBase);
57
+ if (!consumer) {
58
+ out.push(`${rawLine} [No source map found for ${fileBase}]`);
59
+ continue;
60
+ }
61
+ // Try exact col, then small backoff
62
+ let mapped = consumer.originalPositionFor({
63
+ line: genLine,
64
+ column: genCol,
65
+ bias: SourceMapConsumer.GREATEST_LOWER_BOUND,
66
+ });
67
+ if (!mapped.source || mapped.line == null) {
68
+ for (let d = 1; d <= 20; d++) {
69
+ mapped = consumer.originalPositionFor({
70
+ line: genLine,
71
+ column: Math.max(0, genCol - d),
72
+ bias: SourceMapConsumer.GREATEST_LOWER_BOUND,
73
+ });
74
+ if (mapped.source && mapped.line != null)
75
+ break;
64
76
  }
65
77
  }
66
- catch (err) {
67
- mappedStack.push(line);
78
+ if (mapped.source && mapped.line != null) {
79
+ const fn = mapped.name || "anonymous";
80
+ out.push(`${mapped.source}:${mapped.line}:${mapped.column ?? 0} (${fn})`);
81
+ }
82
+ else {
83
+ out.push(`${rawLine} [No mapping found in ${fileBase}]`);
68
84
  }
69
85
  }
70
- return mappedStack;
86
+ return out;
71
87
  }
72
88
  /**
73
89
  * Captures full error details and resolves the stack trace.
@@ -89,9 +105,11 @@ async function captureError(error, isPromiseRejection = false) {
89
105
  }
90
106
  const mappedStack = await resolveStackTrace(stack);
91
107
  const filteredStack = mappedStack.filter((line) => !line.includes("chunk-") && !line.includes("react-dom"));
108
+ const trace = filteredStack.length > 0 ? filteredStack : mappedStack;
92
109
  const errorDetails = {
93
110
  message: errorMessage,
94
111
  stack,
112
+ trace,
95
113
  filteredStack,
96
114
  userAgent: navigator.userAgent,
97
115
  url: window.location.href,
@@ -674,7 +674,7 @@ function startCountdownThenRecord() {
674
674
  let count = 3;
675
675
  overlay.textContent = count.toString();
676
676
  document.body.appendChild(overlay);
677
- const interval = setInterval(() => {
677
+ const interval = setInterval(async () => {
678
678
  count--;
679
679
  if (count > 0) {
680
680
  overlay.textContent = count.toString();
@@ -685,6 +685,14 @@ function startCountdownThenRecord() {
685
685
  // Begin recording
686
686
  recordingStartTime = Date.now();
687
687
  isRecording = true;
688
+ // Enable function span tracking for this recording session
689
+ try {
690
+ const { enableFunctionSpanTracking } = await import("./websocket");
691
+ enableFunctionSpanTracking();
692
+ }
693
+ catch (e) {
694
+ console.error("[Report Issue] Failed to enable function span tracking:", e);
695
+ }
688
696
  closeModal();
689
697
  showFloatingTimer();
690
698
  }
@@ -742,12 +750,20 @@ function showFloatingTimer() {
742
750
  timerEl.textContent = `${mins}:${secs}`;
743
751
  }, 1000);
744
752
  }
745
- function stopRecording() {
753
+ async function stopRecording() {
746
754
  recordingEndTime = Date.now();
747
755
  isRecording = false;
748
756
  if (timerInterval)
749
757
  clearInterval(timerInterval);
750
758
  document.getElementById("sf-recording-indicator")?.remove();
759
+ // Disable function span tracking after recording stops
760
+ try {
761
+ const { disableFunctionSpanTracking } = await import("./websocket");
762
+ disableFunctionSpanTracking();
763
+ }
764
+ catch (e) {
765
+ console.error("[Report Issue] Failed to disable function span tracking:", e);
766
+ }
751
767
  reopenModalAfterStop();
752
768
  }
753
769
  function reopenModalAfterStop() {
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { getUrlAndStoredUuids, initializeConsolePlugin, initializeDomContentEven
12
12
  import { HAS_DOCUMENT, HAS_LOCAL_STORAGE, HAS_SESSION_STORAGE, HAS_WINDOW, } from "./runtimeEnv";
13
13
  import { getOrSetSessionId } from "./session";
14
14
  import { withAppUrlMetadata } from "./utils";
15
- import { sendEvent, sendMessage } from "./websocket";
15
+ import { getFuncSpanHeader, sendEvent, sendMessage } from "./websocket";
16
16
  const DEBUG = readDebugFlag(); // A wrapper around fetch that suppresses connection refused errors
17
17
  // Default list of domains to ignore
18
18
  const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
@@ -167,15 +167,42 @@ function getOrSetUserDeviceUuid() {
167
167
  }
168
168
  // Function to handle resetting the sessionId when the page becomes visible again
169
169
  function handleVisibilityChange() {
170
- if (document.visibilityState === "visible") {
171
- getOrSetSessionId(); // Restore sessionId when the user returns to the page
170
+ const visibilityState = document.visibilityState;
171
+ const timestamp = Date.now();
172
+ // Restore sessionId when the user returns to the page
173
+ if (visibilityState === "visible") {
174
+ getOrSetSessionId();
172
175
  }
176
+ // Send visibility change event for both visible and hidden states
177
+ try {
178
+ const url = window.location.href.split("?")[0];
179
+ sendMessage({
180
+ type: "visibilityChange",
181
+ data: {
182
+ state: visibilityState,
183
+ url,
184
+ timestamp,
185
+ ...getUrlAndStoredUuids(),
186
+ },
187
+ });
188
+ if (DEBUG) {
189
+ console.log(`[Sailfish] Tab became ${visibilityState}, sent visibility change event`);
190
+ }
191
+ }
192
+ catch (error) {
193
+ console.warn("[Sailfish] Failed to send visibility change event:", error);
194
+ }
195
+ // Store visibility state in sessionStorage
196
+ sessionStorage.setItem("tabVisibilityChanged", timestamp.toString());
197
+ sessionStorage.setItem("tabVisibilityState", visibilityState);
173
198
  }
174
- function clearPageVisitUuid() {
199
+ function clearPageVisitDataFromSessionStorage() {
175
200
  if (!HAS_SESSION_STORAGE)
176
201
  return;
177
202
  sessionStorage.removeItem("pageVisitUUID");
178
203
  sessionStorage.removeItem("prevPageVisitUUID");
204
+ sessionStorage.removeItem("tabVisibilityChanged");
205
+ sessionStorage.removeItem("tabVisibilityState");
179
206
  }
180
207
  // Initialize event listeners for visibility change and page unload
181
208
  if (HAS_DOCUMENT) {
@@ -183,8 +210,8 @@ if (HAS_DOCUMENT) {
183
210
  }
184
211
  if (HAS_WINDOW) {
185
212
  window.addEventListener("beforeunload", () => {
186
- window.name = "";
187
- clearPageVisitUuid();
213
+ // window.name = "";
214
+ clearPageVisitDataFromSessionStorage();
188
215
  });
189
216
  }
190
217
  function storeCredentialsAndConnection({ apiKey, backendApi, }) {
@@ -353,11 +380,31 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
353
380
  catch (e) {
354
381
  console.warn(`Could not set X-Sf3-Rid for ${url}`, e);
355
382
  }
356
- // 3️⃣ Track timing
383
+ // Add funcspan header if tracking is enabled
384
+ const funcSpanHeader = getFuncSpanHeader();
385
+ if (funcSpanHeader) {
386
+ try {
387
+ this.setRequestHeader(funcSpanHeader.name, funcSpanHeader.value);
388
+ if (DEBUG) {
389
+ console.log(`[Sailfish] Added funcspan header to XMLHttpRequest:`, {
390
+ url,
391
+ header: funcSpanHeader.name,
392
+ });
393
+ }
394
+ }
395
+ catch (e) {
396
+ if (DEBUG) {
397
+ console.warn(`[Sailfish] Could not set funcspan header for ${url}`, e);
398
+ }
399
+ }
400
+ }
401
+ // 3️⃣ Track timing and capture request data
357
402
  const startTime = Date.now();
358
403
  let finished = false;
404
+ const requestBody = args[0]; // Capture the request body/payload
405
+ const requestHeaders = { ...this._capturedRequestHeaders }; // Capture request headers
359
406
  // 4️⃣ Helper to emit networkRequestFinished
360
- const emitFinished = (success, status, errorMsg) => {
407
+ const emitFinished = (success, status, errorMsg, responseData, responseHeaders) => {
361
408
  if (finished)
362
409
  return;
363
410
  finished = true;
@@ -376,6 +423,10 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
376
423
  error: errorMsg,
377
424
  method: this._requestMethod,
378
425
  url,
426
+ request_headers: requestHeaders,
427
+ request_body: requestBody,
428
+ response_headers: responseHeaders,
429
+ response_body: responseData,
379
430
  },
380
431
  ...getUrlAndStoredUuids(),
381
432
  });
@@ -383,12 +434,42 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
383
434
  // 6️⃣ On successful load
384
435
  this.addEventListener("load", () => {
385
436
  const status = this.status || 0;
437
+ let responseData;
438
+ let responseHeaders = null;
439
+ try {
440
+ // Try to capture response data
441
+ responseData = this.responseText || this.response;
442
+ }
443
+ catch (e) {
444
+ // Response might not be accessible in some cases
445
+ responseData = null;
446
+ }
447
+ // Capture response headers
448
+ try {
449
+ responseHeaders = {};
450
+ const allHeaders = this.getAllResponseHeaders();
451
+ if (allHeaders) {
452
+ // Parse headers string into object
453
+ allHeaders.split('\r\n').forEach(line => {
454
+ const parts = line.split(': ');
455
+ if (parts.length === 2) {
456
+ responseHeaders[parts[0]] = parts[1];
457
+ }
458
+ });
459
+ }
460
+ }
461
+ catch (e) {
462
+ if (DEBUG) {
463
+ console.warn("[Sailfish] Failed to capture XHR response headers:", e);
464
+ }
465
+ responseHeaders = null;
466
+ }
386
467
  if (status >= 200 && status < 300) {
387
- emitFinished(true, status, "");
468
+ emitFinished(true, status, "", responseData, responseHeaders);
388
469
  }
389
470
  else {
390
471
  const msg = this.statusText || `HTTP ${status}`;
391
- emitFinished(false, status, msg);
472
+ emitFinished(false, status, msg, responseData, responseHeaders);
392
473
  }
393
474
  }, { once: true });
394
475
  // 7️⃣ On network/CORS error
@@ -435,7 +516,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
435
516
  return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
436
517
  },
437
518
  });
438
- // 2️⃣ Fetch interceptors injectHeaderWrapper(); emits 'networkRequest' event
519
+ // 2️⃣ Fetch interceptor's injectHeaderWrapper(); emits 'networkRequest' event
439
520
  async function injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url) {
440
521
  if (!sessionId) {
441
522
  return target.apply(thisArg, args);
@@ -444,6 +525,50 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
444
525
  const urlAndStoredUuids = getUrlAndStoredUuids();
445
526
  const method = init.method || "GET";
446
527
  const startTime = Date.now();
528
+ // Capture request headers and body
529
+ let requestHeaders = {};
530
+ let requestBody;
531
+ try {
532
+ if (input instanceof Request) {
533
+ // Extract headers from Request object
534
+ input.headers.forEach((value, key) => {
535
+ requestHeaders[key] = value;
536
+ });
537
+ // Try to clone and read body if present
538
+ try {
539
+ const clonedRequest = input.clone();
540
+ requestBody = await clonedRequest.text();
541
+ }
542
+ catch (e) {
543
+ requestBody = null;
544
+ }
545
+ }
546
+ else {
547
+ // Extract headers from init
548
+ if (init.headers) {
549
+ if (init.headers instanceof Headers) {
550
+ init.headers.forEach((value, key) => {
551
+ requestHeaders[key] = value;
552
+ });
553
+ }
554
+ else if (Array.isArray(init.headers)) {
555
+ init.headers.forEach(([key, value]) => {
556
+ requestHeaders[key] = value;
557
+ });
558
+ }
559
+ else {
560
+ requestHeaders = { ...init.headers };
561
+ }
562
+ }
563
+ // Capture request body
564
+ requestBody = init.body;
565
+ }
566
+ }
567
+ catch (e) {
568
+ if (DEBUG) {
569
+ console.warn("[Sailfish] Failed to capture request data:", e);
570
+ }
571
+ }
447
572
  try {
448
573
  let response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
449
574
  let isRetry = false;
@@ -457,6 +582,33 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
457
582
  const status = response.status;
458
583
  const success = response.ok;
459
584
  const error = success ? "" : `Request Error: ${response.statusText}`;
585
+ // Capture response data
586
+ let responseData;
587
+ try {
588
+ // Clone the response so we don't consume the original stream
589
+ const clonedResponse = response.clone();
590
+ responseData = await clonedResponse.text();
591
+ }
592
+ catch (e) {
593
+ if (DEBUG) {
594
+ console.warn("[Sailfish] Failed to capture response data:", e);
595
+ }
596
+ responseData = null;
597
+ }
598
+ // Capture response headers
599
+ let responseHeaders = null;
600
+ try {
601
+ responseHeaders = {};
602
+ response.headers.forEach((value, key) => {
603
+ responseHeaders[key] = value;
604
+ });
605
+ }
606
+ catch (e) {
607
+ if (DEBUG) {
608
+ console.warn("[Sailfish] Failed to capture response headers:", e);
609
+ }
610
+ responseHeaders = null;
611
+ }
460
612
  // Emit 'networkRequestFinished' event
461
613
  const eventData = {
462
614
  type: NetworkRequestEventId,
@@ -473,6 +625,10 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
473
625
  method,
474
626
  url,
475
627
  retry_without_trace_id: isRetry,
628
+ request_headers: requestHeaders,
629
+ request_body: requestBody,
630
+ response_headers: responseHeaders,
631
+ response_body: responseData,
476
632
  },
477
633
  ...urlAndStoredUuids,
478
634
  };
@@ -503,6 +659,9 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
503
659
  error: errorMessage,
504
660
  method,
505
661
  url,
662
+ request_headers: requestHeaders,
663
+ request_body: requestBody,
664
+ response_body: null,
506
665
  },
507
666
  ...urlAndStoredUuids,
508
667
  };
@@ -512,11 +671,23 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
512
671
  }
513
672
  // Helper function to inject the X-Sf3-Rid header
514
673
  async function injectHeader(target, thisArg, input, init, sessionId, pageVisitUUID, networkUUID) {
674
+ // Get funcspan header if tracking is enabled
675
+ const funcSpanHeader = getFuncSpanHeader();
515
676
  if (input instanceof Request) {
516
677
  // Clone the Request and modify headers
517
678
  const clonedRequest = input.clone();
518
679
  const newHeaders = new Headers(clonedRequest.headers);
519
680
  newHeaders.set(xSf3RidHeader, `${sessionId}/${pageVisitUUID}/${networkUUID}`);
681
+ // Add funcspan header if tracking is enabled
682
+ if (funcSpanHeader) {
683
+ newHeaders.set(funcSpanHeader.name, funcSpanHeader.value);
684
+ if (DEBUG) {
685
+ console.log(`[Sailfish] Added funcspan header to HTTP Request:`, {
686
+ url: input.url,
687
+ header: funcSpanHeader.name,
688
+ });
689
+ }
690
+ }
520
691
  const modifiedRequest = new Request(clonedRequest, {
521
692
  headers: newHeaders,
522
693
  });
@@ -527,6 +698,16 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
527
698
  const modifiedInit = { ...init };
528
699
  const newHeaders = new Headers(init.headers || {});
529
700
  newHeaders.set(xSf3RidHeader, `${sessionId}/${pageVisitUUID}/${networkUUID}`);
701
+ // Add funcspan header if tracking is enabled
702
+ if (funcSpanHeader) {
703
+ newHeaders.set(funcSpanHeader.name, funcSpanHeader.value);
704
+ if (DEBUG) {
705
+ console.log(`[Sailfish] Added funcspan header to HTTP fetch:`, {
706
+ url: typeof input === "string" ? input : input.href,
707
+ header: funcSpanHeader.name,
708
+ });
709
+ }
710
+ }
530
711
  modifiedInit.headers = newHeaders;
531
712
  return await target.call(thisArg, input, modifiedInit);
532
713
  }
@@ -673,6 +854,8 @@ export const initRecorder = async (options) => {
673
854
  return;
674
855
  const g = (window.__sailfish_recorder ||= {});
675
856
  const currentSessionId = getOrSetSessionId();
857
+ // remove stale page visit data from previous sessions
858
+ clearPageVisitDataFromSessionStorage();
676
859
  // If already initialized for this session and socket is open, do nothing.
677
860
  if (g.initialized &&
678
861
  g.sessionId === currentSessionId &&