Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 93 additions & 13 deletions packages/replay/src/coreHandlers/util/fetchUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { logger } from '@sentry/utils';

import type {
FetchHint,
NetworkMetaWarning,
ReplayContainer,
ReplayNetworkOptions,
ReplayNetworkRequestData,
Expand All @@ -16,6 +17,7 @@ import {
getBodySize,
getBodyString,
makeNetworkReplayBreadcrumb,
mergeWarning,
parseContentLengthHeader,
urlMatches,
} from './networkUtils';
Expand Down Expand Up @@ -118,17 +120,24 @@ function _getRequestInfo(

// We only want to transmit string or string-like bodies
const requestBody = _getFetchRequestArgBody(input);
const bodyStr = getBodyString(requestBody);
return buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr);
const [bodyStr, warning] = getBodyString(requestBody);
const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr);

if (warning) {
return mergeWarning(data, warning);
}

return data;
}

async function _getResponseInfo(
/** Exported only for tests. */
export async function _getResponseInfo(
captureDetails: boolean,
{
networkCaptureBodies,
textEncoder,
networkResponseHeaders,
}: ReplayNetworkOptions & {
}: Pick<ReplayNetworkOptions, 'networkCaptureBodies' | 'networkResponseHeaders'> & {
textEncoder: TextEncoderInternal;
},
response: Response | undefined,
Expand All @@ -144,12 +153,39 @@ async function _getResponseInfo(
return buildNetworkRequestOrResponse(headers, responseBodySize, undefined);
}

// Only clone the response if we need to
try {
// We have to clone this, as the body can only be read once
const res = response.clone();
const bodyText = await _parseFetchBody(res);
const [bodyText, warning] = await _parseFetchResponseBody(response);
const result = getResponseData(bodyText, {
networkCaptureBodies,
textEncoder,
responseBodySize,
captureDetails,
headers,
});

if (warning) {
return mergeWarning(result, warning);
}

return result;
}

function getResponseData(
bodyText: string | undefined,
{
networkCaptureBodies,
textEncoder,
responseBodySize,
captureDetails,
headers,
}: {
captureDetails: boolean;
networkCaptureBodies: boolean;
responseBodySize: number | undefined;
headers: Record<string, string>;
textEncoder: TextEncoderInternal;
},
): ReplayNetworkRequestOrResponse | undefined {
try {
const size =
bodyText && bodyText.length && responseBodySize === undefined
? getBodySize(bodyText, textEncoder)
Expand All @@ -171,11 +207,19 @@ async function _getResponseInfo(
}
}

async function _parseFetchBody(response: Response): Promise<string | undefined> {
async function _parseFetchResponseBody(response: Response): Promise<[string | undefined, NetworkMetaWarning?]> {
const res = _tryCloneResponse(response);

if (!res) {
return [undefined, 'BODY_PARSE_ERROR'];
}

try {
return await response.text();
} catch {
return undefined;
const text = await _tryGetResponseText(res);
return [text];
} catch (error) {
__DEBUG_BUILD__ && logger.warn('[Replay] Failed to get text body from response', error);
return [undefined, 'BODY_PARSE_ERROR'];
}
}

Expand Down Expand Up @@ -237,3 +281,39 @@ function getHeadersFromOptions(

return getAllowedHeaders(headers, allowedHeaders);
}

function _tryCloneResponse(response: Response): Response | void {
try {
// We have to clone this, as the body can only be read once
return response.clone();
} catch (error) {
// this can throw if the response was already consumed before
__DEBUG_BUILD__ && logger.warn('[Replay] Failed to clone response body', error);
}
}

/**
* Get the response body of a fetch request, or timeout after 500ms.
* Fetch can return a streaming body, that may not resolve (or not for a long time).
* If that happens, we rather abort after a short time than keep waiting for this.
*/
function _tryGetResponseText(response: Response): Promise<string | undefined> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout while trying to read response body')), 500);

_getResponseText(response)
.then(
txt => resolve(txt),
reason => reject(reason),
)
.finally(() => clearTimeout(timeout));
});

return _getResponseText(response);
}

async function _getResponseText(response: Response): Promise<string> {
// Force this to be a promise, just to be safe
// eslint-disable-next-line no-return-await
return await response.text();
}
34 changes: 29 additions & 5 deletions packages/replay/src/coreHandlers/util/networkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,26 +61,50 @@ export function parseContentLengthHeader(header: string | null | undefined): num
}

/** Get the string representation of a body. */
export function getBodyString(body: unknown): string | undefined {
export function getBodyString(body: unknown): [string | undefined, NetworkMetaWarning?] {
try {
if (typeof body === 'string') {
return body;
return [body];
}

if (body instanceof URLSearchParams) {
return body.toString();
return [body.toString()];
}

if (body instanceof FormData) {
return _serializeFormData(body);
return [_serializeFormData(body)];
}
} catch {
__DEBUG_BUILD__ && logger.warn('[Replay] Failed to serialize body', body);
return [undefined, 'BODY_PARSE_ERROR'];
}

__DEBUG_BUILD__ && logger.info('[Replay] Skipping network body because of body type', body);

return undefined;
return [undefined];
}

/** Merge a warning into an existing network request/response. */
export function mergeWarning(
info: ReplayNetworkRequestOrResponse | undefined,
warning: NetworkMetaWarning,
): ReplayNetworkRequestOrResponse {
if (!info) {
return {
headers: {},
size: undefined,
_meta: {
warnings: [warning],
},
};
}

const newMeta = { ...info._meta };
const existingWarnings = newMeta.warnings || [];
newMeta.warnings = [...existingWarnings, warning];

info._meta = newMeta;
return info;
}

/** Convert ReplayNetworkRequestData to a PerformanceEntry. */
Expand Down
23 changes: 15 additions & 8 deletions packages/replay/src/coreHandlers/util/xhrUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { Breadcrumb, TextEncoderInternal, XhrBreadcrumbData } from '@sentry/types';
import { logger, SENTRY_XHR_DATA_KEY } from '@sentry/utils';

import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types';
import type {
NetworkMetaWarning,
ReplayContainer,
ReplayNetworkOptions,
ReplayNetworkRequestData,
XhrHint,
} from '../../types';
import { addNetworkBreadcrumb } from './addNetworkBreadcrumb';
import {
buildNetworkRequestOrResponse,
Expand All @@ -10,6 +16,7 @@ import {
getBodySize,
getBodyString,
makeNetworkReplayBreadcrumb,
mergeWarning,
parseContentLengthHeader,
urlMatches,
} from './networkUtils';
Expand Down Expand Up @@ -103,8 +110,8 @@ function _prepareXhrData(
: {};
const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders);

const requestBody = options.networkCaptureBodies ? getBodyString(input) : undefined;
const responseBody = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : undefined;
const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input) : [undefined];
const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined];

const request = buildNetworkRequestOrResponse(networkRequestHeaders, requestBodySize, requestBody);
const response = buildNetworkRequestOrResponse(networkResponseHeaders, responseBodySize, responseBody);
Expand All @@ -115,8 +122,8 @@ function _prepareXhrData(
url,
method,
statusCode,
request,
response,
request: requestWarning ? mergeWarning(request, requestWarning) : request,
response: responseWarning ? mergeWarning(response, responseWarning) : response,
};
}

Expand All @@ -134,12 +141,12 @@ function getResponseHeaders(xhr: XMLHttpRequest): Record<string, string> {
}, {});
}

function _getXhrResponseBody(xhr: XMLHttpRequest): string | undefined {
function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkMetaWarning?] {
// We collect errors that happen, but only log them if we can't get any response body
const errors: unknown[] = [];

try {
return xhr.responseText;
return [xhr.responseText];
} catch (e) {
errors.push(e);
}
Expand All @@ -154,5 +161,5 @@ function _getXhrResponseBody(xhr: XMLHttpRequest): string | undefined {

__DEBUG_BUILD__ && logger.warn('[Replay] Failed to get xhr response body', ...errors);

return undefined;
return [undefined];
}
2 changes: 1 addition & 1 deletion packages/replay/src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type JsonArray = unknown[];

export type NetworkBody = JsonObject | JsonArray | string;

export type NetworkMetaWarning = 'MAYBE_JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'URL_SKIPPED';
export type NetworkMetaWarning = 'MAYBE_JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'URL_SKIPPED' | 'BODY_PARSE_ERROR';

interface NetworkMeta {
warnings?: NetworkMetaWarning[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ async function waitForReplayEventBuffer() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

const LARGE_BODY = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 1);
Expand Down
Loading