@sailfish-ai/recorder 1.10.2 → 1.10.4
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/chunks/fiberHook-CEzmPkx_.js +125 -0
- package/dist/chunks/fiberHook-CEzmPkx_.js.br +0 -0
- package/dist/chunks/fiberHook-CEzmPkx_.js.gz +0 -0
- package/dist/chunks/fiberHook-DGANQ2ma.js +130 -0
- package/dist/chunks/fiberHook-DGANQ2ma.js.br +0 -0
- package/dist/chunks/fiberHook-DGANQ2ma.js.gz +0 -0
- package/dist/chunks/rrweb-plugin-console-record-BmAm-Ih_.js +181 -0
- package/dist/chunks/rrweb-plugin-console-record-BmAm-Ih_.js.br +0 -0
- package/dist/chunks/rrweb-plugin-console-record-BmAm-Ih_.js.gz +0 -0
- package/dist/chunks/rrweb-plugin-console-record-Cr-osXuj.js +180 -0
- package/dist/chunks/rrweb-plugin-console-record-Cr-osXuj.js.br +0 -0
- package/dist/chunks/rrweb-plugin-console-record-Cr-osXuj.js.gz +0 -0
- package/dist/chunks/rrweb-record-only-Ba4xyfd6.js +5253 -0
- package/dist/chunks/rrweb-record-only-Ba4xyfd6.js.br +0 -0
- package/dist/chunks/rrweb-record-only-Ba4xyfd6.js.gz +0 -0
- package/dist/chunks/rrweb-record-only-C5Qb-uaQ.js +5253 -0
- package/dist/chunks/rrweb-record-only-C5Qb-uaQ.js.br +0 -0
- package/dist/chunks/rrweb-record-only-C5Qb-uaQ.js.gz +0 -0
- package/dist/inAppReportIssueModal/index.js +171 -129
- package/dist/inAppReportIssueModal/integrations.js +84 -19
- package/dist/inAppReportIssueModal/state.js +1 -0
- package/dist/inAppReportIssueModal/types.js +1 -0
- package/dist/inAppReportIssueModal/ui.js +9 -0
- package/dist/index.js +259 -60
- package/dist/recorder.cjs +1954 -7344
- package/dist/recorder.js +1953 -7344
- package/dist/recorder.js.br +0 -0
- package/dist/recorder.js.gz +0 -0
- package/dist/recording.js +41 -32
- package/dist/session.js +12 -6
- package/dist/types/inAppReportIssueModal/integrations.d.ts +8 -0
- package/dist/types/inAppReportIssueModal/types.d.ts +3 -4
- package/dist/types/index.d.ts +11 -3
- package/dist/types/recording.d.ts +2 -2
- package/dist/types/session.d.ts +1 -0
- package/dist/types/websocket.d.ts +1 -0
- package/dist/websocket.js +11 -10
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,15 +7,26 @@ import { fetchAndSendIp } from "./sendSailfishMessages";
|
|
|
7
7
|
import { readGitSha } from "./env";
|
|
8
8
|
import { initializeErrorInterceptor } from "./errorInterceptor";
|
|
9
9
|
import { fetchCaptureSettings, fetchFunctionSpanTrackingEnabled, sendDomainsToNotPropagateHeaderTo, startRecordingSession, } from "./graphql";
|
|
10
|
-
import { setupIssueReporting } from "./inAppReportIssueModal";
|
|
11
|
-
import { fetchIntegrationData, getIntegrationData, } from "./inAppReportIssueModal/integrations";
|
|
12
10
|
import { sendMapUuidIfAvailable } from "./mapUuid";
|
|
13
11
|
import { getUrlAndStoredUuids, initializeConsolePlugin, initializeDomContentEvents, initializeRecording, } from "./recording";
|
|
14
12
|
import { HAS_DOCUMENT, HAS_LOCAL_STORAGE, HAS_SESSION_STORAGE, HAS_WINDOW, } from "./runtimeEnv";
|
|
15
|
-
import { getOrSetSessionId } from "./session";
|
|
13
|
+
import { ensureSessionListeners, getOrSetSessionId } from "./session";
|
|
16
14
|
import { withAppUrlMetadata } from "./utils";
|
|
17
|
-
import { clearStaleFuncSpanState, getFuncSpanHeader, isFunctionSpanTrackingEnabled, sendEvent, sendMessage, } from "./websocket";
|
|
15
|
+
import { clearStaleFuncSpanState, getFuncSpanHeader, isFunctionSpanTrackingEnabled, restoreFuncSpanState, sendEvent, sendMessage, } from "./websocket";
|
|
18
16
|
const DEBUG = readDebugFlag(); // A wrapper around fetch that suppresses connection refused errors
|
|
17
|
+
// Regex cache for matchUrlWithWildcard() — avoids recompiling on every network request
|
|
18
|
+
const _regexCache = new Map();
|
|
19
|
+
function getCachedRegex(pattern, flags) {
|
|
20
|
+
const key = flags ? `${pattern}|${flags}` : pattern;
|
|
21
|
+
let re = _regexCache.get(key);
|
|
22
|
+
if (!re) {
|
|
23
|
+
re = new RegExp(pattern, flags);
|
|
24
|
+
_regexCache.set(key, re);
|
|
25
|
+
}
|
|
26
|
+
return re;
|
|
27
|
+
}
|
|
28
|
+
// Pre-built Set for O(1) static extension lookups (replaces linear STATIC_EXTENSIONS array scan)
|
|
29
|
+
const STATIC_EXTENSIONS_SET = new Set(STATIC_EXTENSIONS.map(ext => ext.toLowerCase()));
|
|
19
30
|
// Default list of domains to ignore
|
|
20
31
|
const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
|
|
21
32
|
"t.co",
|
|
@@ -57,7 +68,7 @@ export const DEFAULT_CAPTURE_SETTINGS = {
|
|
|
57
68
|
recordSsn: false,
|
|
58
69
|
recordDob: false,
|
|
59
70
|
sampling: {},
|
|
60
|
-
enableFiberTracking:
|
|
71
|
+
enableFiberTracking: false,
|
|
61
72
|
};
|
|
62
73
|
export const DEFAULT_CONSOLE_RECORDING_SETTINGS = {
|
|
63
74
|
level: ["info", "log", "warn", "error"],
|
|
@@ -180,11 +191,7 @@ function sendTimeZone() {
|
|
|
180
191
|
};
|
|
181
192
|
sendMessage(message);
|
|
182
193
|
}
|
|
183
|
-
//
|
|
184
|
-
if (HAS_WINDOW) {
|
|
185
|
-
sendUserDeviceUuid();
|
|
186
|
-
sendTimeZone();
|
|
187
|
-
}
|
|
194
|
+
// Side effects deferred into startRecording() — see _ensureModuleSideEffects()
|
|
188
195
|
// Function to get or set the device & program UUID in localStorage
|
|
189
196
|
function getOrSetUserDeviceUuid() {
|
|
190
197
|
let userDeviceUuid = null;
|
|
@@ -244,15 +251,32 @@ function clearPageVisitDataFromSessionStorage() {
|
|
|
244
251
|
sessionStorage.removeItem("tabVisibilityChanged");
|
|
245
252
|
sessionStorage.removeItem("tabVisibilityState");
|
|
246
253
|
}
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
254
|
+
// Visibility/beforeunload listeners are deferred — see _ensureModuleSideEffects()
|
|
255
|
+
// One-time deferred side effects that were previously at module top-level.
|
|
256
|
+
// Called once at the start of startRecording() to avoid import-time work.
|
|
257
|
+
let _moduleSideEffectsInit = false;
|
|
258
|
+
function _ensureModuleSideEffects() {
|
|
259
|
+
if (_moduleSideEffectsInit)
|
|
260
|
+
return;
|
|
261
|
+
_moduleSideEffectsInit = true;
|
|
262
|
+
// Restore funcspan state from localStorage (was an IIFE in websocket.tsx)
|
|
263
|
+
restoreFuncSpanState();
|
|
264
|
+
// Session beforeunload listener (was module-level in session.tsx)
|
|
265
|
+
ensureSessionListeners();
|
|
266
|
+
// Send standard information
|
|
267
|
+
if (HAS_WINDOW) {
|
|
268
|
+
sendUserDeviceUuid();
|
|
269
|
+
sendTimeZone();
|
|
270
|
+
}
|
|
271
|
+
// Visibility change and page unload listeners
|
|
272
|
+
if (HAS_DOCUMENT) {
|
|
273
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
274
|
+
}
|
|
275
|
+
if (HAS_WINDOW) {
|
|
276
|
+
window.addEventListener("beforeunload", () => {
|
|
277
|
+
clearPageVisitDataFromSessionStorage();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
256
280
|
}
|
|
257
281
|
function storeCredentialsAndConnection({ apiKey, backendApi, }) {
|
|
258
282
|
if (!HAS_SESSION_STORAGE)
|
|
@@ -311,8 +335,8 @@ export function matchUrlWithWildcard(input, patterns) {
|
|
|
311
335
|
const normalizedPatternDomain = patternDomain
|
|
312
336
|
.replace(/\./g, "\\.") // Escape dots for regex
|
|
313
337
|
.replace(/\*/g, ".*"); // Replace '*' with regex to match any characters
|
|
314
|
-
// Create regex for the domain pattern
|
|
315
|
-
const domainRegex =
|
|
338
|
+
// Create regex for the domain pattern (cached)
|
|
339
|
+
const domainRegex = getCachedRegex(`^${normalizedPatternDomain}$`, "i");
|
|
316
340
|
// Strip 'www.' from both the input domain and the pattern domain for comparison
|
|
317
341
|
const strippedDomain = domain.startsWith("www.") ? domain.slice(4) : domain;
|
|
318
342
|
// If pattern specifies a port, match the exact port
|
|
@@ -330,7 +354,7 @@ export function matchUrlWithWildcard(input, patterns) {
|
|
|
330
354
|
const normalizedPatternPath = patternPath
|
|
331
355
|
.replace(/\*/g, ".*") // Replace '*' with regex to match any characters
|
|
332
356
|
.replace(/\/$/, ""); // Remove trailing slashes from pattern
|
|
333
|
-
const pathRegex =
|
|
357
|
+
const pathRegex = getCachedRegex(`^/${normalizedPatternPath}`, "i");
|
|
334
358
|
return pathRegex.test(pathname); // Match the path
|
|
335
359
|
}
|
|
336
360
|
return true; // Domain matched, no path required
|
|
@@ -343,7 +367,7 @@ export function matchUrlWithWildcard(input, patterns) {
|
|
|
343
367
|
const normalizedPatternPath = patternPath
|
|
344
368
|
.replace(/\*/g, ".*") // Replace '*' with regex to match any characters
|
|
345
369
|
.replace(/\/$/, ""); // Remove trailing slashes from pattern
|
|
346
|
-
const pathRegex =
|
|
370
|
+
const pathRegex = getCachedRegex(`^/${normalizedPatternPath}`, "i");
|
|
347
371
|
return pathRegex.test(pathname); // Match the path
|
|
348
372
|
}
|
|
349
373
|
// If no path pattern, only the domain needs to match
|
|
@@ -359,11 +383,11 @@ function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo = []) {
|
|
|
359
383
|
// If we cannot parse, play it safe and do NOT inject headers.
|
|
360
384
|
return true;
|
|
361
385
|
}
|
|
362
|
-
// 1️⃣ STATIC ASSET EXCLUSIONS (by comprehensive file extension list)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
386
|
+
// 1️⃣ STATIC ASSET EXCLUSIONS (by comprehensive file extension list) — O(1) Set lookup
|
|
387
|
+
const lowerPathname = urlObj.pathname.toLowerCase();
|
|
388
|
+
const lastDotIdx = lowerPathname.lastIndexOf(".");
|
|
389
|
+
if (lastDotIdx !== -1 && STATIC_EXTENSIONS_SET.has(lowerPathname.slice(lastDotIdx))) {
|
|
390
|
+
return true;
|
|
367
391
|
}
|
|
368
392
|
// 2️⃣ WILDCARD-BASED EXCLUSION (domain + path)
|
|
369
393
|
// Pass patterns like ["*.cdn.com/*", "api.example.com/v1/*"]
|
|
@@ -377,7 +401,7 @@ function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo = []) {
|
|
|
377
401
|
return false;
|
|
378
402
|
}
|
|
379
403
|
// Updated XMLHttpRequest interceptor with domain exclusion
|
|
380
|
-
function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
404
|
+
function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = [], bodyCaptureConfig = { captureStreamingResponseBody: true, captureResponseBodyMaxMb: 10, captureStreamPrefixKb: 64, captureStreamTimeoutMs: 10000 }) {
|
|
381
405
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
382
406
|
const originalSend = XMLHttpRequest.prototype.send;
|
|
383
407
|
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
|
@@ -480,9 +504,23 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
|
480
504
|
const status = this.status || 0;
|
|
481
505
|
let responseData;
|
|
482
506
|
let responseHeaders = null;
|
|
507
|
+
const maxBodyBytes = bodyCaptureConfig.captureResponseBodyMaxMb * 1024 * 1024;
|
|
483
508
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
509
|
+
if (bodyCaptureConfig.captureResponseBodyMaxMb === 0) {
|
|
510
|
+
// Body capture disabled
|
|
511
|
+
responseData = null;
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
// Try to capture response data
|
|
515
|
+
const rawData = this.responseText || this.response;
|
|
516
|
+
if (typeof rawData === 'string' && rawData.length > maxBodyBytes) {
|
|
517
|
+
// Response exceeds size limit — skip
|
|
518
|
+
responseData = null;
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
responseData = rawData;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
486
524
|
}
|
|
487
525
|
catch (e) {
|
|
488
526
|
// Response might not be accessible in some cases
|
|
@@ -530,9 +568,93 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
|
530
568
|
};
|
|
531
569
|
}
|
|
532
570
|
// Updated fetch interceptor with exclusion handling
|
|
533
|
-
function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
571
|
+
function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureConfig = { captureStreamingResponseBody: true, captureResponseBodyMaxMb: 10, captureStreamPrefixKb: 64, captureStreamTimeoutMs: 10000 }) {
|
|
534
572
|
const originalFetch = window.fetch;
|
|
535
573
|
const sessionId = getOrSetSessionId();
|
|
574
|
+
// --- Streaming detection helpers ---
|
|
575
|
+
// Content types that indicate a streaming response.
|
|
576
|
+
// These responses should NOT have their body fully consumed — only a limited prefix is captured.
|
|
577
|
+
const STREAMING_CONTENT_TYPES = [
|
|
578
|
+
'text/event-stream', // Server-Sent Events (SSE)
|
|
579
|
+
'application/x-ndjson', // Newline-delimited JSON
|
|
580
|
+
'application/stream+json', // Spring WebFlux / reactive streams
|
|
581
|
+
'application/grpc', // gRPC-Web
|
|
582
|
+
'application/grpc-web', // gRPC-Web alternative
|
|
583
|
+
];
|
|
584
|
+
// Content types where body capture should be skipped entirely (binary data as text is useless).
|
|
585
|
+
const SKIP_BODY_CONTENT_TYPES = [
|
|
586
|
+
'application/octet-stream',
|
|
587
|
+
];
|
|
588
|
+
function isStreamingResponse(response) {
|
|
589
|
+
const contentType = response.headers.get('content-type');
|
|
590
|
+
if (!contentType)
|
|
591
|
+
return false;
|
|
592
|
+
const lowerCT = contentType.toLowerCase();
|
|
593
|
+
return STREAMING_CONTENT_TYPES.some(t => lowerCT.includes(t));
|
|
594
|
+
}
|
|
595
|
+
function shouldSkipBodyCapture(response) {
|
|
596
|
+
const contentType = response.headers.get('content-type');
|
|
597
|
+
if (!contentType)
|
|
598
|
+
return false;
|
|
599
|
+
const lowerCT = contentType.toLowerCase();
|
|
600
|
+
return SKIP_BODY_CONTENT_TYPES.some(t => lowerCT.includes(t));
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Reads a limited prefix from a cloned response's ReadableStream body.
|
|
604
|
+
* Respects a byte limit and timeout. Cancels the reader when done to free resources.
|
|
605
|
+
* Returns the captured text or null on error.
|
|
606
|
+
*/
|
|
607
|
+
async function readStreamPrefix(clonedResponse, maxBytes, timeoutMs) {
|
|
608
|
+
const body = clonedResponse.body;
|
|
609
|
+
if (!body)
|
|
610
|
+
return null;
|
|
611
|
+
const reader = body.getReader();
|
|
612
|
+
const decoder = new TextDecoder();
|
|
613
|
+
const chunks = [];
|
|
614
|
+
let totalBytes = 0;
|
|
615
|
+
const readWithLimit = async () => {
|
|
616
|
+
try {
|
|
617
|
+
while (totalBytes < maxBytes) {
|
|
618
|
+
const { done, value } = await reader.read();
|
|
619
|
+
if (done)
|
|
620
|
+
break;
|
|
621
|
+
totalBytes += value.byteLength;
|
|
622
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
623
|
+
}
|
|
624
|
+
// Flush the decoder
|
|
625
|
+
chunks.push(decoder.decode());
|
|
626
|
+
return chunks.join('');
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return chunks.length > 0 ? chunks.join('') : null;
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
try {
|
|
633
|
+
reader.cancel();
|
|
634
|
+
}
|
|
635
|
+
catch { /* ignore */ }
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
try {
|
|
639
|
+
return await Promise.race([
|
|
640
|
+
readWithLimit(),
|
|
641
|
+
new Promise((resolve) => setTimeout(() => {
|
|
642
|
+
try {
|
|
643
|
+
reader.cancel();
|
|
644
|
+
}
|
|
645
|
+
catch { /* ignore */ }
|
|
646
|
+
resolve(chunks.length > 0 ? chunks.join('') : null);
|
|
647
|
+
}, timeoutMs)),
|
|
648
|
+
]);
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
try {
|
|
652
|
+
reader.cancel();
|
|
653
|
+
}
|
|
654
|
+
catch { /* ignore */ }
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
536
658
|
window.fetch = new Proxy(originalFetch, {
|
|
537
659
|
apply: async (target, thisArg, args) => {
|
|
538
660
|
let input = args[0];
|
|
@@ -573,19 +695,21 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
573
695
|
// Capture request headers and body
|
|
574
696
|
let requestHeaders = {};
|
|
575
697
|
let requestBody;
|
|
698
|
+
// For Request inputs, we clone BEFORE fetch (since fetch consumes the body)
|
|
699
|
+
// but defer .text() read to avoid blocking
|
|
700
|
+
let requestBodyClone = null;
|
|
576
701
|
try {
|
|
577
702
|
if (input instanceof Request) {
|
|
578
703
|
// Extract headers from Request object
|
|
579
704
|
input.headers.forEach((value, key) => {
|
|
580
705
|
requestHeaders[key] = value;
|
|
581
706
|
});
|
|
582
|
-
//
|
|
707
|
+
// Clone the Request NOW (before fetch consumes it) for deferred body reading
|
|
583
708
|
try {
|
|
584
|
-
|
|
585
|
-
requestBody = await clonedRequest.text();
|
|
709
|
+
requestBodyClone = input.clone();
|
|
586
710
|
}
|
|
587
711
|
catch (e) {
|
|
588
|
-
|
|
712
|
+
requestBodyClone = null;
|
|
589
713
|
}
|
|
590
714
|
}
|
|
591
715
|
else {
|
|
@@ -605,7 +729,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
605
729
|
requestHeaders = { ...init.headers };
|
|
606
730
|
}
|
|
607
731
|
}
|
|
608
|
-
// Capture request body
|
|
732
|
+
// Capture request body (non-blocking for string/object bodies)
|
|
609
733
|
requestBody = init.body;
|
|
610
734
|
}
|
|
611
735
|
}
|
|
@@ -643,20 +767,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
643
767
|
const status = response.status;
|
|
644
768
|
const success = response.ok;
|
|
645
769
|
const error = success ? "" : `Request Error: ${response.statusText}`;
|
|
646
|
-
// Capture response
|
|
647
|
-
let responseData;
|
|
648
|
-
try {
|
|
649
|
-
// Clone the response so we don't consume the original stream
|
|
650
|
-
const clonedResponse = response.clone();
|
|
651
|
-
responseData = await clonedResponse.text();
|
|
652
|
-
}
|
|
653
|
-
catch (e) {
|
|
654
|
-
if (DEBUG) {
|
|
655
|
-
console.warn("[Sailfish] Failed to capture response data:", e);
|
|
656
|
-
}
|
|
657
|
-
responseData = null;
|
|
658
|
-
}
|
|
659
|
-
// Capture response headers
|
|
770
|
+
// Capture response headers (non-blocking — just reads header map)
|
|
660
771
|
let responseHeaders = null;
|
|
661
772
|
try {
|
|
662
773
|
responseHeaders = {};
|
|
@@ -670,8 +781,8 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
670
781
|
}
|
|
671
782
|
responseHeaders = null;
|
|
672
783
|
}
|
|
673
|
-
//
|
|
674
|
-
const
|
|
784
|
+
// Build the base event data (response_body filled asynchronously below)
|
|
785
|
+
const baseEventData = {
|
|
675
786
|
type: NetworkRequestEventId,
|
|
676
787
|
timestamp: endTime,
|
|
677
788
|
sessionId,
|
|
@@ -689,11 +800,75 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
689
800
|
request_headers: requestHeaders,
|
|
690
801
|
request_body: requestBody,
|
|
691
802
|
response_headers: responseHeaders,
|
|
692
|
-
response_body:
|
|
803
|
+
response_body: null,
|
|
693
804
|
},
|
|
694
805
|
...urlAndStoredUuids,
|
|
695
806
|
};
|
|
696
|
-
|
|
807
|
+
// Helper to resolve deferred request body and send event
|
|
808
|
+
const sendEventWithBody = (responseData) => {
|
|
809
|
+
baseEventData.data.response_body = responseData;
|
|
810
|
+
if (requestBodyClone) {
|
|
811
|
+
requestBodyClone.text().then((body) => { baseEventData.data.request_body = body; sendEvent(baseEventData); }, () => { sendEvent(baseEventData); });
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
sendEvent(baseEventData);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
const maxBodyBytes = bodyCaptureConfig.captureResponseBodyMaxMb * 1024 * 1024;
|
|
818
|
+
// --- Determine body capture strategy and return response IMMEDIATELY ---
|
|
819
|
+
if (bodyCaptureConfig.captureResponseBodyMaxMb === 0) {
|
|
820
|
+
// All body capture disabled
|
|
821
|
+
sendEventWithBody(null);
|
|
822
|
+
}
|
|
823
|
+
else if (shouldSkipBodyCapture(response)) {
|
|
824
|
+
// Binary content type — text capture is useless
|
|
825
|
+
sendEventWithBody(null);
|
|
826
|
+
}
|
|
827
|
+
else if (isStreamingResponse(response)) {
|
|
828
|
+
// Streaming response
|
|
829
|
+
if (bodyCaptureConfig.captureStreamingResponseBody) {
|
|
830
|
+
// Tee the stream: clone, capture limited prefix in background, cancel reader when done
|
|
831
|
+
try {
|
|
832
|
+
const clonedResponse = response.clone();
|
|
833
|
+
readStreamPrefix(clonedResponse, bodyCaptureConfig.captureStreamPrefixKb * 1024, bodyCaptureConfig.captureStreamTimeoutMs).then((prefix) => sendEventWithBody(prefix), () => sendEventWithBody(null));
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
sendEventWithBody(null);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
// Streaming body capture disabled — don't clone, send immediately
|
|
841
|
+
sendEventWithBody(null);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// Non-streaming response — check Content-Length against size limit
|
|
846
|
+
const contentLength = response.headers.get('content-length');
|
|
847
|
+
const declaredSize = contentLength ? parseInt(contentLength, 10) : NaN;
|
|
848
|
+
if (!isNaN(declaredSize) && declaredSize > maxBodyBytes) {
|
|
849
|
+
// Response too large — skip body capture
|
|
850
|
+
sendEventWithBody(null);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
// Clone and read body asynchronously (fire-and-forget)
|
|
854
|
+
try {
|
|
855
|
+
const clonedResponse = response.clone();
|
|
856
|
+
clonedResponse.text().then((text) => {
|
|
857
|
+
// Double-check actual size after reading (Content-Length may be absent/wrong)
|
|
858
|
+
if (text.length > maxBodyBytes) {
|
|
859
|
+
sendEventWithBody(null);
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
sendEventWithBody(text);
|
|
863
|
+
}
|
|
864
|
+
}, () => sendEventWithBody(null));
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
sendEventWithBody(null);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// CRITICAL: Return response to caller IMMEDIATELY — never block on body capture
|
|
697
872
|
return response;
|
|
698
873
|
}
|
|
699
874
|
catch (error) {
|
|
@@ -706,6 +881,16 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
706
881
|
// CORS/network failure: exclude just this path
|
|
707
882
|
return target.apply(thisArg, args);
|
|
708
883
|
}
|
|
884
|
+
// Resolve deferred request body for error telemetry
|
|
885
|
+
let resolvedRequestBody = requestBody;
|
|
886
|
+
if (requestBodyClone) {
|
|
887
|
+
try {
|
|
888
|
+
resolvedRequestBody = await requestBodyClone.text();
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
resolvedRequestBody = null;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
709
894
|
const eventData = {
|
|
710
895
|
type: NetworkRequestEventId,
|
|
711
896
|
timestamp: endTime,
|
|
@@ -721,7 +906,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
721
906
|
method,
|
|
722
907
|
url,
|
|
723
908
|
request_headers: requestHeaders,
|
|
724
|
-
request_body:
|
|
909
|
+
request_body: resolvedRequestBody,
|
|
725
910
|
response_body: null,
|
|
726
911
|
},
|
|
727
912
|
...urlAndStoredUuids,
|
|
@@ -824,7 +1009,9 @@ function getMapUuidFromWindow() {
|
|
|
824
1009
|
// Note - we do NOT send serviceIdentifier because
|
|
825
1010
|
// it would be 1 serviceIdentifier per frontend user session,
|
|
826
1011
|
// which is very wasteful
|
|
827
|
-
export async function startRecording({ apiKey, backendApi = "https://api-service.sailfishqa.com", domainsToPropagateHeaderTo = [], domainsToNotPropagateHeaderTo = [], serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, }) {
|
|
1012
|
+
export async function startRecording({ apiKey, backendApi = "https://api-service.sailfishqa.com", domainsToPropagateHeaderTo = [], domainsToNotPropagateHeaderTo = [], serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody = true, captureResponseBodyMaxMb = 10, captureStreamPrefixKb = 64, captureStreamTimeoutMs = 10000, }) {
|
|
1013
|
+
// Initialize deferred module-level side effects (one-time)
|
|
1014
|
+
_ensureModuleSideEffects();
|
|
828
1015
|
const effectiveGitSha = gitSha ?? readGitSha();
|
|
829
1016
|
const effectiveServiceIdentifier = serviceIdentifier ?? "";
|
|
830
1017
|
const effectiveServiceVersion = serviceVersion ?? "";
|
|
@@ -895,13 +1082,20 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
|
|
|
895
1082
|
], backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
|
|
896
1083
|
g.sentDoNotPropagateOnce = true;
|
|
897
1084
|
}
|
|
1085
|
+
// Network body capture config
|
|
1086
|
+
const bodyCaptureConfig = {
|
|
1087
|
+
captureStreamingResponseBody,
|
|
1088
|
+
captureResponseBodyMaxMb,
|
|
1089
|
+
captureStreamPrefixKb,
|
|
1090
|
+
captureStreamTimeoutMs,
|
|
1091
|
+
};
|
|
898
1092
|
// Patch XHR/fetch once per window
|
|
899
1093
|
if (!g.xhrPatched) {
|
|
900
|
-
setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo);
|
|
1094
|
+
setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo, bodyCaptureConfig);
|
|
901
1095
|
g.xhrPatched = true;
|
|
902
1096
|
}
|
|
903
1097
|
if (!g.fetchPatched) {
|
|
904
|
-
setupFetchInterceptor(domainsToNotPropagateHeaderTo);
|
|
1098
|
+
setupFetchInterceptor(domainsToNotPropagateHeaderTo, bodyCaptureConfig);
|
|
905
1099
|
g.fetchPatched = true;
|
|
906
1100
|
}
|
|
907
1101
|
gatherAndCacheDeviceInfo();
|
|
@@ -967,9 +1161,14 @@ export const initRecorder = async (options) => {
|
|
|
967
1161
|
g.hasLoggedInitOnce = true;
|
|
968
1162
|
}
|
|
969
1163
|
await startRecording(options);
|
|
970
|
-
// Set up the issue reporting UI once
|
|
1164
|
+
// Set up the issue reporting UI once (lazy-loaded)
|
|
971
1165
|
if (!g.issueReportingInit) {
|
|
972
1166
|
const backendApiUrl = options.backendApi ?? "https://api-service.sailfishqa.com";
|
|
1167
|
+
// Dynamically import issue reporting modules to reduce initial bundle
|
|
1168
|
+
const [{ setupIssueReporting }, { fetchIntegrationData, getIntegrationData }] = await Promise.all([
|
|
1169
|
+
import("./inAppReportIssueModal"),
|
|
1170
|
+
import("./inAppReportIssueModal/integrations"),
|
|
1171
|
+
]);
|
|
973
1172
|
// Fetch integration data before setting up issue reporting
|
|
974
1173
|
let integrationData = null;
|
|
975
1174
|
try {
|