Skip to content

Commit 5f65bbe

Browse files
committed
fix(replay): Capture JSON XHR response bodies
1 parent f8cebde commit 5f65bbe

File tree

2 files changed

+138
-3
lines changed
  • packages
    • browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody
    • replay/src/coreHandlers/util

2 files changed

+138
-3
lines changed

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

+87
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/xhrUtils.ts

+51-3
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,52 @@ 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+
export function _parseXhrResponse(
177+
body: XMLHttpRequest['response'],
178+
responseType: XMLHttpRequest['responseType'],
179+
): [string | undefined, NetworkMetaWarning?] {
180+
logger.log(body, responseType, typeof body);
181+
try {
182+
if (typeof body === 'string') {
183+
return [body];
184+
}
185+
186+
if (body instanceof Document) {
187+
return [body.body.outerHTML];
188+
}
189+
190+
if (responseType === 'json' && body && typeof body === 'object') {
191+
return [JSON.stringify(body)];
192+
}
193+
} catch {
194+
__DEBUG_BUILD__ && logger.warn('[Replay] Failed to serialize body', body);
195+
return [undefined, 'BODY_PARSE_ERROR'];
196+
}
197+
198+
__DEBUG_BUILD__ && logger.info('[Replay] Skipping network body because of body type', body);
199+
200+
return [undefined];
201+
}
202+
203+
function _getBodySize(
204+
body: XMLHttpRequest['response'],
205+
responseType: XMLHttpRequest['responseType'],
206+
textEncoder: TextEncoder | TextEncoderInternal,
207+
): number | undefined {
208+
try {
209+
const bodyStr = responseType === 'json' && body && typeof body === 'object' ? JSON.stringify(body) : body;
210+
return getBodySize(bodyStr, textEncoder);
211+
} catch {
212+
return undefined;
213+
}
214+
}

0 commit comments

Comments
 (0)