Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '80 KB',
limit: '82 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand Down Expand Up @@ -89,7 +89,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'),
gzip: true,
limit: '97 KB',
limit: '98 KB',
},
{
name: '@sentry/browser (incl. Feedback)',
Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,22 @@

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

- feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))
- **feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))**

- **feat(replay): Add Request body with `attachRawBodyFromRequest` option ([#18501](https://github.com/getsentry/sentry-javascript/pull/18501))**

To attach the raw request body (from `Request` objects passed as the first `fetch` argument) to replay events,
you can now use the `attachRawBodyFromRequest` option in the Replay integration:

```js
Sentry.init({
integrations: [
Sentry.replayIntegration({
attachRawBodyFromRequest: true,
}),
],
});
```

## 10.31.0

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = Sentry.replayIntegration({
flushMinDelay: 200,
flushMaxDelay: 200,
minReplayDuration: 0,

networkDetailAllowUrls: ['http://sentry-test.io/foo'],
networkCaptureBodies: true,
attachRawBodyFromRequest: true,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1,
// We ensure to sample for errors, so by default nothing is sent
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,

integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h1>attachRawBodyFromRequest Test</h1>
</body>
</html>

Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { PlaywrightTestArgs } from '@playwright/test';
import { expect } from '@playwright/test';
import type { TestFixtures } from '../../../utils/fixtures';
import { sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers';
import { collectReplayRequests, getReplayPerformanceSpans, shouldSkipReplayTest } from '../../../utils/replayHelpers';

/**
* Shared helper to run the common test flow
*/
async function runRequestFetchTest(
{ page, getLocalTestUrl }: { page: PlaywrightTestArgs['page']; getLocalTestUrl: TestFixtures['getLocalTestUrl'] },
options: {
evaluateFn: () => void;
expectedBody: any;
expectedSize: number | any;
expectedExtraReplayData?: any;
},
) {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

await page.route('http://sentry-test.io/foo', route => route.fulfill({ status: 200 }));

const requestPromise = waitForErrorRequest(page);
const replayRequestPromise = collectReplayRequests(page, recordingEvents =>
getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'),
);

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);
await page.evaluate(options.evaluateFn);

// Envelope/Breadcrumbs
const eventData = envelopeRequestParser(await requestPromise);
expect(eventData.exception?.values).toHaveLength(1);

const fetchBreadcrumbs = eventData?.breadcrumbs?.filter(b => b.category === 'fetch');
expect(fetchBreadcrumbs).toHaveLength(1);
expect(fetchBreadcrumbs![0]).toEqual({
timestamp: expect.any(Number),
category: 'fetch',
type: 'http',
data: {
method: 'POST',
request_body_size: options.expectedSize,
status_code: 200,
url: 'http://sentry-test.io/foo',
},
});

// Replay Spans
const { replayRecordingSnapshots } = await replayRequestPromise;
const fetchSpans = getReplayPerformanceSpans(replayRecordingSnapshots).filter(s => s.op === 'resource.fetch');
expect(fetchSpans).toHaveLength(1);

expect(fetchSpans[0]).toMatchObject({
data: {
method: 'POST',
statusCode: 200,
request: {
body: options.expectedBody,
},
...options.expectedExtraReplayData,
},
description: 'http://sentry-test.io/foo',
endTimestamp: expect.any(Number),
op: 'resource.fetch',
startTimestamp: expect.any(Number),
});
}

sentryTest('captures request body when using Request object with text body', async ({ page, getLocalTestUrl }) => {
await runRequestFetchTest(
{ page, getLocalTestUrl },
{
evaluateFn: () => {
const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: 'Request body text' });
// @ts-expect-error Sentry is a global
fetch(request).then(() => Sentry.captureException('test error'));
},
expectedBody: 'Request body text',
expectedSize: 17,
},
);
});

sentryTest('captures request body when using Request object with JSON body', async ({ page, getLocalTestUrl }) => {
await runRequestFetchTest(
{ page, getLocalTestUrl },
{
evaluateFn: () => {
const request = new Request('http://sentry-test.io/foo', {
method: 'POST',
body: JSON.stringify({ name: 'John', age: 30 }),
});
// @ts-expect-error Sentry is a global
fetch(request).then(() => Sentry.captureException('test error'));
},
expectedBody: { name: 'John', age: 30 },
expectedSize: expect.any(Number),
},
);
});

sentryTest('prioritizes options body over Request object body', async ({ page, getLocalTestUrl, browserName }) => {
const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined;

await runRequestFetchTest(
{ page, getLocalTestUrl },
{
evaluateFn: () => {
const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: 'original body' });
// Second argument body should override the Request body
// @ts-expect-error Sentry is a global
fetch(request, { body: 'override body' }).then(() => Sentry.captureException('test error'));
},
expectedBody: 'override body',
expectedSize: 13,
expectedExtraReplayData: {
request: { size: 13, headers: {} }, // Specific override structure check
...(additionalHeaders && { response: { headers: additionalHeaders } }),
},
},
);
});

sentryTest('captures request body with FormData in Request object', async ({ page, getLocalTestUrl }) => {
await runRequestFetchTest(
{ page, getLocalTestUrl },
{
evaluateFn: () => {
const params = new URLSearchParams();
params.append('key1', 'value1');
params.append('key2', 'value2');
const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: params });
// @ts-expect-error Sentry is a global
fetch(request).then(() => Sentry.captureException('test error'));
},
expectedBody: 'key1=value1&key2=value2',
expectedSize: 23,
},
);
});
25 changes: 21 additions & 4 deletions packages/browser-utils/src/networkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { debug } from '@sentry/core';
import { DEBUG_BUILD } from './debug-build';
import type { NetworkMetaWarning } from './types';

// Symbol used by e.g. the Replay integration to store original body on Request objects
export const ORIGINAL_REQ_BODY = Symbol.for('sentry__originalRequestBody');

/**
* Serializes FormData.
*
Expand Down Expand Up @@ -45,14 +48,28 @@ export function getBodyString(body: unknown, _debug: typeof debug = debug): [str
/**
* Parses the fetch arguments to extract the request payload.
*
* We only support getting the body from the fetch options.
* In case of a Request object, this function attempts to retrieve the original body by looking for a Sentry-patched symbol.
*/
export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined {
if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') {
return undefined;
// Second argument with body options takes precedence
if (fetchArgs.length >= 2 && fetchArgs[1] && typeof fetchArgs[1] === 'object' && 'body' in fetchArgs[1]) {
return (fetchArgs[1] as RequestInit).body;
}

if (fetchArgs.length >= 1 && fetchArgs[0] instanceof Request) {
const request = fetchArgs[0];
/* The Request interface's body is a ReadableStream, which we cannot directly access.
Some integrations (e.g. Replay) patch the Request object to store the original body. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const originalBody = (request as any)[ORIGINAL_REQ_BODY];
if (originalBody !== undefined) {
return originalBody;
}
Comment on lines +65 to +67
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: It is possible that the user creates a req object with a stream body, should we explicitly return undefined here or is it ignored safely at the caller?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, we would also attach the stream. It's a very unlikely use case because it would mean that you create a ReadableStream yourself and attach it to a Request, which results in a double-nested readable stream 🤔


return undefined; // Fall back to returning undefined (as we don't want to return a ReadableStream)
}

return (fetchArgs[1] as RequestInit).body;
return undefined;
}

/**
Expand Down
Loading
Loading