@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 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
- // Try to capture response data
485
- responseData = this.responseText || this.response;
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
- // Try to clone and read body if present
683
+ // Clone the Request NOW (before fetch consumes it) for deferred body reading
583
684
  try {
584
- const clonedRequest = input.clone();
585
- requestBody = await clonedRequest.text();
685
+ requestBodyClone = input.clone();
586
686
  }
587
687
  catch (e) {
588
- requestBody = null;
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 data
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
- // Emit 'networkRequestFinished' event
674
- const eventData = {
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: responseData,
779
+ response_body: null,
693
780
  },
694
781
  ...urlAndStoredUuids,
695
782
  };
696
- sendEvent(eventData);
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: requestBody,
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();