Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

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

### Important Changes

- **feat(browser): Add support for GraphQL persisted operations ([#18505](https://github.com/getsentry/sentry-javascript/pull/18505))**
Expand All @@ -25,9 +27,22 @@ Additionally, the `graphql.document` attribute format has changed to align with
"graphql.document": "query Test { user { id } }"
```

### Other Changes
- **feat(replay): Add Request body with `attachRawBodyFromRequest` option**

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
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,
}),
],
});
```

### Other Changes

Work in this release was contributed by @sebws. Thank you for your contribution!

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