Skip to content

Commit 9476ae3

Browse files
committed
fix(replay): Capture JSON XHR response bodies
1 parent 14b8092 commit 9476ae3

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

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

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

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

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

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

0 commit comments

Comments
 (0)