Skip to content

Commit f3fbcd9

Browse files
authored
feat(replay): Add Request body with attachRawBodyFromRequest option (#18501)
Previously, we only got the body from the `fetch` options. However, some people use the `Request` object which can also contain a body. As this body is a `ReadableStream`, we could only read that async. With `attachRawBodyFromRequest`, the `Request` object is patched to include the raw body as a `Symbol` so it can be read synchronously. Linear ticket (internal): https://linear.app/getsentry/issue/JS-1255/investigate-sdk-issues-causing-all-requests-to-default-to-get
1 parent 191ed1d commit f3fbcd9

File tree

11 files changed

+673
-9
lines changed

11 files changed

+673
-9
lines changed

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ module.exports = [
5252
path: 'packages/browser/build/npm/esm/prod/index.js',
5353
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
5454
gzip: true,
55-
limit: '80 KB',
55+
limit: '82 KB',
5656
},
5757
{
5858
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
@@ -89,7 +89,7 @@ module.exports = [
8989
path: 'packages/browser/build/npm/esm/prod/index.js',
9090
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'),
9191
gzip: true,
92-
limit: '97 KB',
92+
limit: '98 KB',
9393
},
9494
{
9595
name: '@sentry/browser (incl. Feedback)',

CHANGELOG.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,22 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7-
- feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))
7+
- **feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))**
8+
9+
- **feat(replay): Add Request body with `attachRawBodyFromRequest` option ([#18501](https://github.com/getsentry/sentry-javascript/pull/18501))**
10+
11+
To attach the raw request body (from `Request` objects passed as the first `fetch` argument) to replay events,
12+
you can now use the `attachRawBodyFromRequest` option in the Replay integration:
13+
14+
```js
15+
Sentry.init({
16+
integrations: [
17+
Sentry.replayIntegration({
18+
attachRawBodyFromRequest: true,
19+
}),
20+
],
21+
});
22+
```
823

924
## 10.31.0
1025

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = Sentry.replayIntegration({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
9+
networkDetailAllowUrls: ['http://sentry-test.io/foo'],
10+
networkCaptureBodies: true,
11+
attachRawBodyFromRequest: true,
12+
});
13+
14+
Sentry.init({
15+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
16+
sampleRate: 1,
17+
// We ensure to sample for errors, so by default nothing is sent
18+
replaysSessionSampleRate: 0.0,
19+
replaysOnErrorSampleRate: 1.0,
20+
21+
integrations: [window.Replay],
22+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<h1>attachRawBodyFromRequest Test</h1>
8+
</body>
9+
</html>
10+
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { PlaywrightTestArgs } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import type { TestFixtures } from '../../../utils/fixtures';
4+
import { sentryTest } from '../../../utils/fixtures';
5+
import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers';
6+
import { collectReplayRequests, getReplayPerformanceSpans, shouldSkipReplayTest } from '../../../utils/replayHelpers';
7+
8+
/**
9+
* Shared helper to run the common test flow
10+
*/
11+
async function runRequestFetchTest(
12+
{ page, getLocalTestUrl }: { page: PlaywrightTestArgs['page']; getLocalTestUrl: TestFixtures['getLocalTestUrl'] },
13+
options: {
14+
evaluateFn: () => void;
15+
expectedBody: any;
16+
expectedSize: number | any;
17+
expectedExtraReplayData?: any;
18+
},
19+
) {
20+
if (shouldSkipReplayTest()) {
21+
sentryTest.skip();
22+
}
23+
24+
await page.route('http://sentry-test.io/foo', route => route.fulfill({ status: 200 }));
25+
26+
const requestPromise = waitForErrorRequest(page);
27+
const replayRequestPromise = collectReplayRequests(page, recordingEvents =>
28+
getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'),
29+
);
30+
31+
const url = await getLocalTestUrl({ testDir: __dirname });
32+
await page.goto(url);
33+
await page.evaluate(options.evaluateFn);
34+
35+
// Envelope/Breadcrumbs
36+
const eventData = envelopeRequestParser(await requestPromise);
37+
expect(eventData.exception?.values).toHaveLength(1);
38+
39+
const fetchBreadcrumbs = eventData?.breadcrumbs?.filter(b => b.category === 'fetch');
40+
expect(fetchBreadcrumbs).toHaveLength(1);
41+
expect(fetchBreadcrumbs![0]).toEqual({
42+
timestamp: expect.any(Number),
43+
category: 'fetch',
44+
type: 'http',
45+
data: {
46+
method: 'POST',
47+
request_body_size: options.expectedSize,
48+
status_code: 200,
49+
url: 'http://sentry-test.io/foo',
50+
},
51+
});
52+
53+
// Replay Spans
54+
const { replayRecordingSnapshots } = await replayRequestPromise;
55+
const fetchSpans = getReplayPerformanceSpans(replayRecordingSnapshots).filter(s => s.op === 'resource.fetch');
56+
expect(fetchSpans).toHaveLength(1);
57+
58+
expect(fetchSpans[0]).toMatchObject({
59+
data: {
60+
method: 'POST',
61+
statusCode: 200,
62+
request: {
63+
body: options.expectedBody,
64+
},
65+
...options.expectedExtraReplayData,
66+
},
67+
description: 'http://sentry-test.io/foo',
68+
endTimestamp: expect.any(Number),
69+
op: 'resource.fetch',
70+
startTimestamp: expect.any(Number),
71+
});
72+
}
73+
74+
sentryTest('captures request body when using Request object with text body', async ({ page, getLocalTestUrl }) => {
75+
await runRequestFetchTest(
76+
{ page, getLocalTestUrl },
77+
{
78+
evaluateFn: () => {
79+
const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: 'Request body text' });
80+
// @ts-expect-error Sentry is a global
81+
fetch(request).then(() => Sentry.captureException('test error'));
82+
},
83+
expectedBody: 'Request body text',
84+
expectedSize: 17,
85+
},
86+
);
87+
});
88+
89+
sentryTest('captures request body when using Request object with JSON body', async ({ page, getLocalTestUrl }) => {
90+
await runRequestFetchTest(
91+
{ page, getLocalTestUrl },
92+
{
93+
evaluateFn: () => {
94+
const request = new Request('http://sentry-test.io/foo', {
95+
method: 'POST',
96+
body: JSON.stringify({ name: 'John', age: 30 }),
97+
});
98+
// @ts-expect-error Sentry is a global
99+
fetch(request).then(() => Sentry.captureException('test error'));
100+
},
101+
expectedBody: { name: 'John', age: 30 },
102+
expectedSize: expect.any(Number),
103+
},
104+
);
105+
});
106+
107+
sentryTest('prioritizes options body over Request object body', async ({ page, getLocalTestUrl, browserName }) => {
108+
const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined;
109+
110+
await runRequestFetchTest(
111+
{ page, getLocalTestUrl },
112+
{
113+
evaluateFn: () => {
114+
const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: 'original body' });
115+
// Second argument body should override the Request body
116+
// @ts-expect-error Sentry is a global
117+
fetch(request, { body: 'override body' }).then(() => Sentry.captureException('test error'));
118+
},
119+
expectedBody: 'override body',
120+
expectedSize: 13,
121+
expectedExtraReplayData: {
122+
request: { size: 13, headers: {} }, // Specific override structure check
123+
...(additionalHeaders && { response: { headers: additionalHeaders } }),
124+
},
125+
},
126+
);
127+
});
128+
129+
sentryTest('captures request body with FormData in Request object', async ({ page, getLocalTestUrl }) => {
130+
await runRequestFetchTest(
131+
{ page, getLocalTestUrl },
132+
{
133+
evaluateFn: () => {
134+
const params = new URLSearchParams();
135+
params.append('key1', 'value1');
136+
params.append('key2', 'value2');
137+
const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: params });
138+
// @ts-expect-error Sentry is a global
139+
fetch(request).then(() => Sentry.captureException('test error'));
140+
},
141+
expectedBody: 'key1=value1&key2=value2',
142+
expectedSize: 23,
143+
},
144+
);
145+
});

packages/browser-utils/src/networkUtils.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { debug } from '@sentry/core';
22
import { DEBUG_BUILD } from './debug-build';
33
import type { NetworkMetaWarning } from './types';
44

5+
// Symbol used by e.g. the Replay integration to store original body on Request objects
6+
export const ORIGINAL_REQ_BODY = Symbol.for('sentry__originalRequestBody');
7+
58
/**
69
* Serializes FormData.
710
*
@@ -45,14 +48,28 @@ export function getBodyString(body: unknown, _debug: typeof debug = debug): [str
4548
/**
4649
* Parses the fetch arguments to extract the request payload.
4750
*
48-
* We only support getting the body from the fetch options.
51+
* In case of a Request object, this function attempts to retrieve the original body by looking for a Sentry-patched symbol.
4952
*/
5053
export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined {
51-
if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') {
52-
return undefined;
54+
// Second argument with body options takes precedence
55+
if (fetchArgs.length >= 2 && fetchArgs[1] && typeof fetchArgs[1] === 'object' && 'body' in fetchArgs[1]) {
56+
return (fetchArgs[1] as RequestInit).body;
57+
}
58+
59+
if (fetchArgs.length >= 1 && fetchArgs[0] instanceof Request) {
60+
const request = fetchArgs[0];
61+
/* The Request interface's body is a ReadableStream, which we cannot directly access.
62+
Some integrations (e.g. Replay) patch the Request object to store the original body. */
63+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
64+
const originalBody = (request as any)[ORIGINAL_REQ_BODY];
65+
if (originalBody !== undefined) {
66+
return originalBody;
67+
}
68+
69+
return undefined; // Fall back to returning undefined (as we don't want to return a ReadableStream)
5370
}
5471

55-
return (fetchArgs[1] as RequestInit).body;
72+
return undefined;
5673
}
5774

5875
/**

0 commit comments

Comments
 (0)