@sailfish-ai/recorder 1.10.2 → 1.10.3
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 +199 -31
- package/dist/recorder.cjs +499 -423
- package/dist/recorder.js +519 -443
- package/dist/recorder.js.br +0 -0
- package/dist/recorder.js.gz +0 -0
- package/dist/types/index.d.ts +9 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -377,7 +377,7 @@ function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeaderTo = []) {
|
|
|
377
377
|
return false;
|
|
378
378
|
}
|
|
379
379
|
// Updated XMLHttpRequest interceptor with domain exclusion
|
|
380
|
-
function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
380
|
+
function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = [], bodyCaptureConfig = { captureStreamingResponseBody: true, captureResponseBodyMaxMb: 10, captureStreamPrefixKb: 64, captureStreamTimeoutMs: 10000 }) {
|
|
381
381
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
382
382
|
const originalSend = XMLHttpRequest.prototype.send;
|
|
383
383
|
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
|
@@ -480,9 +480,23 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
|
480
480
|
const status = this.status || 0;
|
|
481
481
|
let responseData;
|
|
482
482
|
let responseHeaders = null;
|
|
483
|
+
const maxBodyBytes = bodyCaptureConfig.captureResponseBodyMaxMb * 1024 * 1024;
|
|
483
484
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
485
|
+
if (bodyCaptureConfig.captureResponseBodyMaxMb === 0) {
|
|
486
|
+
// Body capture disabled
|
|
487
|
+
responseData = null;
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
// Try to capture response data
|
|
491
|
+
const rawData = this.responseText || this.response;
|
|
492
|
+
if (typeof rawData === 'string' && rawData.length > maxBodyBytes) {
|
|
493
|
+
// Response exceeds size limit — skip
|
|
494
|
+
responseData = null;
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
responseData = rawData;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
486
500
|
}
|
|
487
501
|
catch (e) {
|
|
488
502
|
// Response might not be accessible in some cases
|
|
@@ -530,9 +544,93 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
|
530
544
|
};
|
|
531
545
|
}
|
|
532
546
|
// Updated fetch interceptor with exclusion handling
|
|
533
|
-
function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
547
|
+
function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureConfig = { captureStreamingResponseBody: true, captureResponseBodyMaxMb: 10, captureStreamPrefixKb: 64, captureStreamTimeoutMs: 10000 }) {
|
|
534
548
|
const originalFetch = window.fetch;
|
|
535
549
|
const sessionId = getOrSetSessionId();
|
|
550
|
+
// --- Streaming detection helpers ---
|
|
551
|
+
// Content types that indicate a streaming response.
|
|
552
|
+
// These responses should NOT have their body fully consumed — only a limited prefix is captured.
|
|
553
|
+
const STREAMING_CONTENT_TYPES = [
|
|
554
|
+
'text/event-stream', // Server-Sent Events (SSE)
|
|
555
|
+
'application/x-ndjson', // Newline-delimited JSON
|
|
556
|
+
'application/stream+json', // Spring WebFlux / reactive streams
|
|
557
|
+
'application/grpc', // gRPC-Web
|
|
558
|
+
'application/grpc-web', // gRPC-Web alternative
|
|
559
|
+
];
|
|
560
|
+
// Content types where body capture should be skipped entirely (binary data as text is useless).
|
|
561
|
+
const SKIP_BODY_CONTENT_TYPES = [
|
|
562
|
+
'application/octet-stream',
|
|
563
|
+
];
|
|
564
|
+
function isStreamingResponse(response) {
|
|
565
|
+
const contentType = response.headers.get('content-type');
|
|
566
|
+
if (!contentType)
|
|
567
|
+
return false;
|
|
568
|
+
const lowerCT = contentType.toLowerCase();
|
|
569
|
+
return STREAMING_CONTENT_TYPES.some(t => lowerCT.includes(t));
|
|
570
|
+
}
|
|
571
|
+
function shouldSkipBodyCapture(response) {
|
|
572
|
+
const contentType = response.headers.get('content-type');
|
|
573
|
+
if (!contentType)
|
|
574
|
+
return false;
|
|
575
|
+
const lowerCT = contentType.toLowerCase();
|
|
576
|
+
return SKIP_BODY_CONTENT_TYPES.some(t => lowerCT.includes(t));
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Reads a limited prefix from a cloned response's ReadableStream body.
|
|
580
|
+
* Respects a byte limit and timeout. Cancels the reader when done to free resources.
|
|
581
|
+
* Returns the captured text or null on error.
|
|
582
|
+
*/
|
|
583
|
+
async function readStreamPrefix(clonedResponse, maxBytes, timeoutMs) {
|
|
584
|
+
const body = clonedResponse.body;
|
|
585
|
+
if (!body)
|
|
586
|
+
return null;
|
|
587
|
+
const reader = body.getReader();
|
|
588
|
+
const decoder = new TextDecoder();
|
|
589
|
+
const chunks = [];
|
|
590
|
+
let totalBytes = 0;
|
|
591
|
+
const readWithLimit = async () => {
|
|
592
|
+
try {
|
|
593
|
+
while (totalBytes < maxBytes) {
|
|
594
|
+
const { done, value } = await reader.read();
|
|
595
|
+
if (done)
|
|
596
|
+
break;
|
|
597
|
+
totalBytes += value.byteLength;
|
|
598
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
599
|
+
}
|
|
600
|
+
// Flush the decoder
|
|
601
|
+
chunks.push(decoder.decode());
|
|
602
|
+
return chunks.join('');
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return chunks.length > 0 ? chunks.join('') : null;
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
try {
|
|
609
|
+
reader.cancel();
|
|
610
|
+
}
|
|
611
|
+
catch { /* ignore */ }
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
try {
|
|
615
|
+
return await Promise.race([
|
|
616
|
+
readWithLimit(),
|
|
617
|
+
new Promise((resolve) => setTimeout(() => {
|
|
618
|
+
try {
|
|
619
|
+
reader.cancel();
|
|
620
|
+
}
|
|
621
|
+
catch { /* ignore */ }
|
|
622
|
+
resolve(chunks.length > 0 ? chunks.join('') : null);
|
|
623
|
+
}, timeoutMs)),
|
|
624
|
+
]);
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
try {
|
|
628
|
+
reader.cancel();
|
|
629
|
+
}
|
|
630
|
+
catch { /* ignore */ }
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
536
634
|
window.fetch = new Proxy(originalFetch, {
|
|
537
635
|
apply: async (target, thisArg, args) => {
|
|
538
636
|
let input = args[0];
|
|
@@ -573,19 +671,21 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
573
671
|
// Capture request headers and body
|
|
574
672
|
let requestHeaders = {};
|
|
575
673
|
let requestBody;
|
|
674
|
+
// For Request inputs, we clone BEFORE fetch (since fetch consumes the body)
|
|
675
|
+
// but defer .text() read to avoid blocking
|
|
676
|
+
let requestBodyClone = null;
|
|
576
677
|
try {
|
|
577
678
|
if (input instanceof Request) {
|
|
578
679
|
// Extract headers from Request object
|
|
579
680
|
input.headers.forEach((value, key) => {
|
|
580
681
|
requestHeaders[key] = value;
|
|
581
682
|
});
|
|
582
|
-
//
|
|
683
|
+
// Clone the Request NOW (before fetch consumes it) for deferred body reading
|
|
583
684
|
try {
|
|
584
|
-
|
|
585
|
-
requestBody = await clonedRequest.text();
|
|
685
|
+
requestBodyClone = input.clone();
|
|
586
686
|
}
|
|
587
687
|
catch (e) {
|
|
588
|
-
|
|
688
|
+
requestBodyClone = null;
|
|
589
689
|
}
|
|
590
690
|
}
|
|
591
691
|
else {
|
|
@@ -605,7 +705,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
605
705
|
requestHeaders = { ...init.headers };
|
|
606
706
|
}
|
|
607
707
|
}
|
|
608
|
-
// Capture request body
|
|
708
|
+
// Capture request body (non-blocking for string/object bodies)
|
|
609
709
|
requestBody = init.body;
|
|
610
710
|
}
|
|
611
711
|
}
|
|
@@ -643,20 +743,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
643
743
|
const status = response.status;
|
|
644
744
|
const success = response.ok;
|
|
645
745
|
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
|
|
746
|
+
// Capture response headers (non-blocking — just reads header map)
|
|
660
747
|
let responseHeaders = null;
|
|
661
748
|
try {
|
|
662
749
|
responseHeaders = {};
|
|
@@ -670,8 +757,8 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
670
757
|
}
|
|
671
758
|
responseHeaders = null;
|
|
672
759
|
}
|
|
673
|
-
//
|
|
674
|
-
const
|
|
760
|
+
// Build the base event data (response_body filled asynchronously below)
|
|
761
|
+
const baseEventData = {
|
|
675
762
|
type: NetworkRequestEventId,
|
|
676
763
|
timestamp: endTime,
|
|
677
764
|
sessionId,
|
|
@@ -689,11 +776,75 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
689
776
|
request_headers: requestHeaders,
|
|
690
777
|
request_body: requestBody,
|
|
691
778
|
response_headers: responseHeaders,
|
|
692
|
-
response_body:
|
|
779
|
+
response_body: null,
|
|
693
780
|
},
|
|
694
781
|
...urlAndStoredUuids,
|
|
695
782
|
};
|
|
696
|
-
|
|
783
|
+
// Helper to resolve deferred request body and send event
|
|
784
|
+
const sendEventWithBody = (responseData) => {
|
|
785
|
+
baseEventData.data.response_body = responseData;
|
|
786
|
+
if (requestBodyClone) {
|
|
787
|
+
requestBodyClone.text().then((body) => { baseEventData.data.request_body = body; sendEvent(baseEventData); }, () => { sendEvent(baseEventData); });
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
sendEvent(baseEventData);
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const maxBodyBytes = bodyCaptureConfig.captureResponseBodyMaxMb * 1024 * 1024;
|
|
794
|
+
// --- Determine body capture strategy and return response IMMEDIATELY ---
|
|
795
|
+
if (bodyCaptureConfig.captureResponseBodyMaxMb === 0) {
|
|
796
|
+
// All body capture disabled
|
|
797
|
+
sendEventWithBody(null);
|
|
798
|
+
}
|
|
799
|
+
else if (shouldSkipBodyCapture(response)) {
|
|
800
|
+
// Binary content type — text capture is useless
|
|
801
|
+
sendEventWithBody(null);
|
|
802
|
+
}
|
|
803
|
+
else if (isStreamingResponse(response)) {
|
|
804
|
+
// Streaming response
|
|
805
|
+
if (bodyCaptureConfig.captureStreamingResponseBody) {
|
|
806
|
+
// Tee the stream: clone, capture limited prefix in background, cancel reader when done
|
|
807
|
+
try {
|
|
808
|
+
const clonedResponse = response.clone();
|
|
809
|
+
readStreamPrefix(clonedResponse, bodyCaptureConfig.captureStreamPrefixKb * 1024, bodyCaptureConfig.captureStreamTimeoutMs).then((prefix) => sendEventWithBody(prefix), () => sendEventWithBody(null));
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
sendEventWithBody(null);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
// Streaming body capture disabled — don't clone, send immediately
|
|
817
|
+
sendEventWithBody(null);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
// Non-streaming response — check Content-Length against size limit
|
|
822
|
+
const contentLength = response.headers.get('content-length');
|
|
823
|
+
const declaredSize = contentLength ? parseInt(contentLength, 10) : NaN;
|
|
824
|
+
if (!isNaN(declaredSize) && declaredSize > maxBodyBytes) {
|
|
825
|
+
// Response too large — skip body capture
|
|
826
|
+
sendEventWithBody(null);
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
// Clone and read body asynchronously (fire-and-forget)
|
|
830
|
+
try {
|
|
831
|
+
const clonedResponse = response.clone();
|
|
832
|
+
clonedResponse.text().then((text) => {
|
|
833
|
+
// Double-check actual size after reading (Content-Length may be absent/wrong)
|
|
834
|
+
if (text.length > maxBodyBytes) {
|
|
835
|
+
sendEventWithBody(null);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
sendEventWithBody(text);
|
|
839
|
+
}
|
|
840
|
+
}, () => sendEventWithBody(null));
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
sendEventWithBody(null);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// CRITICAL: Return response to caller IMMEDIATELY — never block on body capture
|
|
697
848
|
return response;
|
|
698
849
|
}
|
|
699
850
|
catch (error) {
|
|
@@ -706,6 +857,16 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
706
857
|
// CORS/network failure: exclude just this path
|
|
707
858
|
return target.apply(thisArg, args);
|
|
708
859
|
}
|
|
860
|
+
// Resolve deferred request body for error telemetry
|
|
861
|
+
let resolvedRequestBody = requestBody;
|
|
862
|
+
if (requestBodyClone) {
|
|
863
|
+
try {
|
|
864
|
+
resolvedRequestBody = await requestBodyClone.text();
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
resolvedRequestBody = null;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
709
870
|
const eventData = {
|
|
710
871
|
type: NetworkRequestEventId,
|
|
711
872
|
timestamp: endTime,
|
|
@@ -721,7 +882,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
721
882
|
method,
|
|
722
883
|
url,
|
|
723
884
|
request_headers: requestHeaders,
|
|
724
|
-
request_body:
|
|
885
|
+
request_body: resolvedRequestBody,
|
|
725
886
|
response_body: null,
|
|
726
887
|
},
|
|
727
888
|
...urlAndStoredUuids,
|
|
@@ -824,7 +985,7 @@ function getMapUuidFromWindow() {
|
|
|
824
985
|
// Note - we do NOT send serviceIdentifier because
|
|
825
986
|
// it would be 1 serviceIdentifier per frontend user session,
|
|
826
987
|
// which is very wasteful
|
|
827
|
-
export async function startRecording({ apiKey, backendApi = "https://api-service.sailfishqa.com", domainsToPropagateHeaderTo = [], domainsToNotPropagateHeaderTo = [], serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, }) {
|
|
988
|
+
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, }) {
|
|
828
989
|
const effectiveGitSha = gitSha ?? readGitSha();
|
|
829
990
|
const effectiveServiceIdentifier = serviceIdentifier ?? "";
|
|
830
991
|
const effectiveServiceVersion = serviceVersion ?? "";
|
|
@@ -895,13 +1056,20 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
|
|
|
895
1056
|
], backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
|
|
896
1057
|
g.sentDoNotPropagateOnce = true;
|
|
897
1058
|
}
|
|
1059
|
+
// Network body capture config
|
|
1060
|
+
const bodyCaptureConfig = {
|
|
1061
|
+
captureStreamingResponseBody,
|
|
1062
|
+
captureResponseBodyMaxMb,
|
|
1063
|
+
captureStreamPrefixKb,
|
|
1064
|
+
captureStreamTimeoutMs,
|
|
1065
|
+
};
|
|
898
1066
|
// Patch XHR/fetch once per window
|
|
899
1067
|
if (!g.xhrPatched) {
|
|
900
|
-
setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo);
|
|
1068
|
+
setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo, bodyCaptureConfig);
|
|
901
1069
|
g.xhrPatched = true;
|
|
902
1070
|
}
|
|
903
1071
|
if (!g.fetchPatched) {
|
|
904
|
-
setupFetchInterceptor(domainsToNotPropagateHeaderTo);
|
|
1072
|
+
setupFetchInterceptor(domainsToNotPropagateHeaderTo, bodyCaptureConfig);
|
|
905
1073
|
g.fetchPatched = true;
|
|
906
1074
|
}
|
|
907
1075
|
gatherAndCacheDeviceInfo();
|