Skip to content

Commit

Permalink
fix(replay): Capture JSON XHR response bodies (#9623)
Browse files Browse the repository at this point in the history
This is stupid, now that I figured it out. Basically, if you set
`xhr.responseType = 'json'`, it will force `xhr.response` to be a POJO -
which we can't parse right now.

We now handle this case specifically. 

This also adds a new `UNPARSEABLE_BODY_TYPE` meta warning if we are not
getting a body because it is not matching any of the known/parsed types.

Closes #9339
  • Loading branch information
mydea committed Dec 4, 2023
1 parent f8cebde commit 6a382a9
Show file tree
Hide file tree
Showing 7 changed files with 342 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,93 @@ sentryTest('captures JSON response body', async ({ getLocalTestPath, page, brows
]);
});

sentryTest('captures JSON response body when responseType=json', async ({ getLocalTestPath, page, browserName }) => {
// These are a bit flaky on non-chromium browsers
if (shouldSkipReplayTest() || browserName !== 'chromium') {
sentryTest.skip();
}

await page.route('**/foo', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({ res: 'this' }),
headers: {
'Content-Length': '',
},
});
});

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const requestPromise = waitForErrorRequest(page);
const replayRequestPromise1 = waitForReplayRequest(page, 0);

const url = await getLocalTestPath({ testDir: __dirname });
await page.goto(url);

void page.evaluate(() => {
/* eslint-disable */
const xhr = new XMLHttpRequest();

xhr.open('POST', 'http://localhost:7654/foo');
// Setting this to json ensures that xhr.response returns a POJO
xhr.responseType = 'json';
xhr.send();

xhr.addEventListener('readystatechange', function () {
if (xhr.readyState === 4) {
// @ts-expect-error Sentry is a global
setTimeout(() => Sentry.captureException('test error', 0));
}
});
/* eslint-enable */
});

const request = await requestPromise;
const eventData = envelopeRequestParser(request);

expect(eventData.exception?.values).toHaveLength(1);

expect(eventData?.breadcrumbs?.length).toBe(1);
expect(eventData!.breadcrumbs![0]).toEqual({
timestamp: expect.any(Number),
category: 'xhr',
type: 'http',
data: {
method: 'POST',
response_body_size: 14,
status_code: 200,
url: 'http://localhost:7654/foo',
},
});

const replayReq1 = await replayRequestPromise1;
const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1);
expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([
{
data: {
method: 'POST',
statusCode: 200,
response: {
size: 14,
headers: {},
body: { res: 'this' },
},
},
description: 'http://localhost:7654/foo',
endTimestamp: expect.any(Number),
op: 'resource.xhr',
startTimestamp: expect.any(Number),
},
]);
});

sentryTest('captures non-text response body', async ({ getLocalTestPath, page, browserName }) => {
// These are a bit flaky on non-chromium browsers
if (shouldSkipReplayTest() || browserName !== 'chromium') {
Expand Down
6 changes: 5 additions & 1 deletion packages/replay/src/coreHandlers/util/networkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,18 @@ export function getBodyString(body: unknown): [string | undefined, NetworkMetaWa
if (body instanceof FormData) {
return [_serializeFormData(body)];
}

if (!body) {
return [undefined];
}
} 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, 'UNPARSEABLE_BODY_TYPE'];
}

/** Merge a warning into an existing network request/response. */
Expand Down
59 changes: 56 additions & 3 deletions packages/replay/src/coreHandlers/util/xhrUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function enrichXhrBreadcrumb(
const reqSize = getBodySize(input, options.textEncoder);
const resSize = xhr.getResponseHeader('content-length')
? parseContentLengthHeader(xhr.getResponseHeader('content-length'))
: getBodySize(xhr.response, options.textEncoder);
: _getBodySize(xhr.response, xhr.responseType, options.textEncoder);

if (reqSize !== undefined) {
breadcrumb.data.request_body_size = reqSize;
Expand Down Expand Up @@ -154,8 +154,7 @@ function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkM

// Try to manually parse the response body, if responseText fails
try {
const response = xhr.response;
return getBodyString(response);
return _parseXhrResponse(xhr.response, xhr.responseType);
} catch (e) {
errors.push(e);
}
Expand All @@ -164,3 +163,57 @@ function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkM

return [undefined];
}

/**
* Get the string representation of the XHR response.
* Based on MDN, these are the possible types of the response:
* string
* ArrayBuffer
* Blob
* Document
* POJO
*
* Exported only for tests.
*/
export function _parseXhrResponse(
body: XMLHttpRequest['response'],
responseType: XMLHttpRequest['responseType'],
): [string | undefined, NetworkMetaWarning?] {
try {
if (typeof body === 'string') {
return [body];
}

if (body instanceof Document) {
return [body.body.outerHTML];
}

if (responseType === 'json' && body && typeof body === 'object') {
return [JSON.stringify(body)];
}

if (!body) {
return [undefined];
}
} 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, 'UNPARSEABLE_BODY_TYPE'];
}

function _getBodySize(
body: XMLHttpRequest['response'],
responseType: XMLHttpRequest['responseType'],
textEncoder: TextEncoder | TextEncoderInternal,
): number | undefined {
try {
const bodyStr = responseType === 'json' && body && typeof body === 'object' ? JSON.stringify(body) : body;
return getBodySize(bodyStr, textEncoder);
} catch {
return undefined;
}
}
7 changes: 6 additions & 1 deletion packages/replay/src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ type JsonArray = unknown[];

export type NetworkBody = JsonObject | JsonArray | string;

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

interface NetworkMeta {
warnings?: NetworkMetaWarning[];
Expand Down

0 comments on commit 6a382a9

Please sign in to comment.