Skip to content

Commit 6a382a9

Browse files
authored
fix(replay): Capture JSON XHR response bodies (#9623)
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
1 parent f8cebde commit 6a382a9

File tree

7 files changed

+342
-117
lines changed

7 files changed

+342
-117
lines changed

packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,93 @@ sentryTest('captures JSON response body', async ({ getLocalTestPath, page, brows
178178
]);
179179
});
180180

181+
sentryTest('captures JSON response body when responseType=json', async ({ getLocalTestPath, page, browserName }) => {
182+
// These are a bit flaky on non-chromium browsers
183+
if (shouldSkipReplayTest() || browserName !== 'chromium') {
184+
sentryTest.skip();
185+
}
186+
187+
await page.route('**/foo', route => {
188+
return route.fulfill({
189+
status: 200,
190+
body: JSON.stringify({ res: 'this' }),
191+
headers: {
192+
'Content-Length': '',
193+
},
194+
});
195+
});
196+
197+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
198+
return route.fulfill({
199+
status: 200,
200+
contentType: 'application/json',
201+
body: JSON.stringify({ id: 'test-id' }),
202+
});
203+
});
204+
205+
const requestPromise = waitForErrorRequest(page);
206+
const replayRequestPromise1 = waitForReplayRequest(page, 0);
207+
208+
const url = await getLocalTestPath({ testDir: __dirname });
209+
await page.goto(url);
210+
211+
void page.evaluate(() => {
212+
/* eslint-disable */
213+
const xhr = new XMLHttpRequest();
214+
215+
xhr.open('POST', 'http://localhost:7654/foo');
216+
// Setting this to json ensures that xhr.response returns a POJO
217+
xhr.responseType = 'json';
218+
xhr.send();
219+
220+
xhr.addEventListener('readystatechange', function () {
221+
if (xhr.readyState === 4) {
222+
// @ts-expect-error Sentry is a global
223+
setTimeout(() => Sentry.captureException('test error', 0));
224+
}
225+
});
226+
/* eslint-enable */
227+
});
228+
229+
const request = await requestPromise;
230+
const eventData = envelopeRequestParser(request);
231+
232+
expect(eventData.exception?.values).toHaveLength(1);
233+
234+
expect(eventData?.breadcrumbs?.length).toBe(1);
235+
expect(eventData!.breadcrumbs![0]).toEqual({
236+
timestamp: expect.any(Number),
237+
category: 'xhr',
238+
type: 'http',
239+
data: {
240+
method: 'POST',
241+
response_body_size: 14,
242+
status_code: 200,
243+
url: 'http://localhost:7654/foo',
244+
},
245+
});
246+
247+
const replayReq1 = await replayRequestPromise1;
248+
const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1);
249+
expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([
250+
{
251+
data: {
252+
method: 'POST',
253+
statusCode: 200,
254+
response: {
255+
size: 14,
256+
headers: {},
257+
body: { res: 'this' },
258+
},
259+
},
260+
description: 'http://localhost:7654/foo',
261+
endTimestamp: expect.any(Number),
262+
op: 'resource.xhr',
263+
startTimestamp: expect.any(Number),
264+
},
265+
]);
266+
});
267+
181268
sentryTest('captures non-text response body', async ({ getLocalTestPath, page, browserName }) => {
182269
// These are a bit flaky on non-chromium browsers
183270
if (shouldSkipReplayTest() || browserName !== 'chromium') {

packages/replay/src/coreHandlers/util/networkUtils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,18 @@ export function getBodyString(body: unknown): [string | undefined, NetworkMetaWa
7575
if (body instanceof FormData) {
7676
return [_serializeFormData(body)];
7777
}
78+
79+
if (!body) {
80+
return [undefined];
81+
}
7882
} catch {
7983
DEBUG_BUILD && logger.warn('[Replay] Failed to serialize body', body);
8084
return [undefined, 'BODY_PARSE_ERROR'];
8185
}
8286

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

85-
return [undefined];
89+
return [undefined, 'UNPARSEABLE_BODY_TYPE'];
8690
}
8791

8892
/** Merge a warning into an existing network request/response. */

packages/replay/src/coreHandlers/util/xhrUtils.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function enrichXhrBreadcrumb(
6161
const reqSize = getBodySize(input, options.textEncoder);
6262
const resSize = xhr.getResponseHeader('content-length')
6363
? parseContentLengthHeader(xhr.getResponseHeader('content-length'))
64-
: getBodySize(xhr.response, options.textEncoder);
64+
: _getBodySize(xhr.response, xhr.responseType, options.textEncoder);
6565

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

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

165164
return [undefined];
166165
}
166+
167+
/**
168+
* Get the string representation of the XHR response.
169+
* Based on MDN, these are the possible types of the response:
170+
* string
171+
* ArrayBuffer
172+
* Blob
173+
* Document
174+
* POJO
175+
*
176+
* Exported only for tests.
177+
*/
178+
export function _parseXhrResponse(
179+
body: XMLHttpRequest['response'],
180+
responseType: XMLHttpRequest['responseType'],
181+
): [string | undefined, NetworkMetaWarning?] {
182+
try {
183+
if (typeof body === 'string') {
184+
return [body];
185+
}
186+
187+
if (body instanceof Document) {
188+
return [body.body.outerHTML];
189+
}
190+
191+
if (responseType === 'json' && body && typeof body === 'object') {
192+
return [JSON.stringify(body)];
193+
}
194+
195+
if (!body) {
196+
return [undefined];
197+
}
198+
} catch {
199+
DEBUG_BUILD && logger.warn('[Replay] Failed to serialize body', body);
200+
return [undefined, 'BODY_PARSE_ERROR'];
201+
}
202+
203+
DEBUG_BUILD && logger.info('[Replay] Skipping network body because of body type', body);
204+
205+
return [undefined, 'UNPARSEABLE_BODY_TYPE'];
206+
}
207+
208+
function _getBodySize(
209+
body: XMLHttpRequest['response'],
210+
responseType: XMLHttpRequest['responseType'],
211+
textEncoder: TextEncoder | TextEncoderInternal,
212+
): number | undefined {
213+
try {
214+
const bodyStr = responseType === 'json' && body && typeof body === 'object' ? JSON.stringify(body) : body;
215+
return getBodySize(bodyStr, textEncoder);
216+
} catch {
217+
return undefined;
218+
}
219+
}

packages/replay/src/types/request.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ type JsonArray = unknown[];
33

44
export type NetworkBody = JsonObject | JsonArray | string;
55

6-
export type NetworkMetaWarning = 'MAYBE_JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'URL_SKIPPED' | 'BODY_PARSE_ERROR';
6+
export type NetworkMetaWarning =
7+
| 'MAYBE_JSON_TRUNCATED'
8+
| 'TEXT_TRUNCATED'
9+
| 'URL_SKIPPED'
10+
| 'BODY_PARSE_ERROR'
11+
| 'UNPARSEABLE_BODY_TYPE';
712

813
interface NetworkMeta {
914
warnings?: NetworkMetaWarning[];

0 commit comments

Comments
 (0)