Skip to content

Commit 249302a

Browse files
authored
feat(browser): Add new v7 Fetch Transport (#4765)
This PR creates the new v7 Fetch Transport, and updates the browser backend to use the new transport. To configure the transport, users can supply `requestOptions`, which is supplied to the fetch request. This consolidates the earlier pattern of passing in both headers and fetchParameters that the old fetch transport used to use.
1 parent 4ab3abb commit 249302a

File tree

9 files changed

+203
-4
lines changed

9 files changed

+203
-4
lines changed

packages/browser/src/backend.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { BaseBackend } from '@sentry/core';
2-
import { Event, EventHint, Options, Severity, Transport } from '@sentry/types';
1+
import { BaseBackend, getEnvelopeEndpointWithUrlEncodedAuth, initAPIDetails } from '@sentry/core';
2+
import { Event, EventHint, Options, Severity, Transport, TransportOptions } from '@sentry/types';
33
import { supportsFetch } from '@sentry/utils';
44

55
import { eventFromException, eventFromMessage } from './eventbuilder';
6-
import { FetchTransport, XHRTransport } from './transports';
6+
import { FetchTransport, makeNewFetchTransport, XHRTransport } from './transports';
77

88
/**
99
* Configuration options for the Sentry Browser SDK.
@@ -58,18 +58,23 @@ export class BrowserBackend extends BaseBackend<BrowserOptions> {
5858
return super._setupTransport();
5959
}
6060

61-
const transportOptions = {
61+
const transportOptions: TransportOptions = {
6262
...this._options.transportOptions,
6363
dsn: this._options.dsn,
6464
tunnel: this._options.tunnel,
6565
sendClientReports: this._options.sendClientReports,
6666
_metadata: this._options._metadata,
6767
};
6868

69+
const api = initAPIDetails(transportOptions.dsn, transportOptions._metadata, transportOptions.tunnel);
70+
const url = getEnvelopeEndpointWithUrlEncodedAuth(api.dsn, api.tunnel);
71+
6972
if (this._options.transport) {
7073
return new this._options.transport(transportOptions);
7174
}
7275
if (supportsFetch()) {
76+
const requestOptions: RequestInit = { ...transportOptions.fetchParameters };
77+
this._newTransport = makeNewFetchTransport({ requestOptions, url });
7378
return new FetchTransport(transportOptions);
7479
}
7580
return new XHRTransport(transportOptions);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { BaseTransport } from './base';
22
export { FetchTransport } from './fetch';
33
export { XHRTransport } from './xhr';
4+
5+
export { makeNewFetchTransport } from './new-fetch';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
BaseTransportOptions,
3+
createTransport,
4+
NewTransport,
5+
TransportMakeRequestResponse,
6+
TransportRequest,
7+
} from '@sentry/core';
8+
9+
import { FetchImpl, getNativeFetchImplementation } from './utils';
10+
11+
export interface FetchTransportOptions extends BaseTransportOptions {
12+
requestOptions?: RequestInit;
13+
}
14+
15+
/**
16+
* Creates a Transport that uses the Fetch API to send events to Sentry.
17+
*/
18+
export function makeNewFetchTransport(
19+
options: FetchTransportOptions,
20+
nativeFetch: FetchImpl = getNativeFetchImplementation(),
21+
): NewTransport {
22+
function makeRequest(request: TransportRequest): PromiseLike<TransportMakeRequestResponse> {
23+
const requestOptions: RequestInit = {
24+
body: request.body,
25+
method: 'POST',
26+
referrerPolicy: 'origin',
27+
...options.requestOptions,
28+
};
29+
30+
return nativeFetch(options.url, requestOptions).then(response => {
31+
return response.text().then(body => ({
32+
body,
33+
headers: {
34+
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
35+
'retry-after': response.headers.get('Retry-After'),
36+
},
37+
reason: response.statusText,
38+
statusCode: response.status,
39+
}));
40+
});
41+
}
42+
43+
return createTransport({ bufferSize: options.bufferSize }, makeRequest);
44+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { EventEnvelope, EventItem } from '@sentry/types';
2+
import { createEnvelope, serializeEnvelope } from '@sentry/utils';
3+
4+
import { FetchTransportOptions, makeNewFetchTransport } from '../../../src/transports/new-fetch';
5+
import { FetchImpl } from '../../../src/transports/utils';
6+
7+
const DEFAULT_FETCH_TRANSPORT_OPTIONS: FetchTransportOptions = {
8+
url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7',
9+
};
10+
11+
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
12+
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
13+
]);
14+
15+
class Headers {
16+
headers: { [key: string]: string } = {};
17+
get(key: string) {
18+
return this.headers[key] || null;
19+
}
20+
set(key: string, value: string) {
21+
this.headers[key] = value;
22+
}
23+
}
24+
25+
describe('NewFetchTransport', () => {
26+
it('calls fetch with the given URL', async () => {
27+
const mockFetch = jest.fn(() =>
28+
Promise.resolve({
29+
headers: new Headers(),
30+
status: 200,
31+
text: () => Promise.resolve({}),
32+
}),
33+
) as unknown as FetchImpl;
34+
const transport = makeNewFetchTransport(DEFAULT_FETCH_TRANSPORT_OPTIONS, mockFetch);
35+
36+
expect(mockFetch).toHaveBeenCalledTimes(0);
37+
const res = await transport.send(ERROR_ENVELOPE);
38+
expect(mockFetch).toHaveBeenCalledTimes(1);
39+
40+
expect(res.status).toBe('success');
41+
42+
expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, {
43+
body: serializeEnvelope(ERROR_ENVELOPE),
44+
method: 'POST',
45+
referrerPolicy: 'origin',
46+
});
47+
});
48+
49+
it('sets rate limit headers', async () => {
50+
const headers = {
51+
get: jest.fn(),
52+
};
53+
54+
const mockFetch = jest.fn(() =>
55+
Promise.resolve({
56+
headers,
57+
status: 200,
58+
text: () => Promise.resolve({}),
59+
}),
60+
) as unknown as FetchImpl;
61+
const transport = makeNewFetchTransport(DEFAULT_FETCH_TRANSPORT_OPTIONS, mockFetch);
62+
63+
expect(headers.get).toHaveBeenCalledTimes(0);
64+
await transport.send(ERROR_ENVELOPE);
65+
66+
expect(headers.get).toHaveBeenCalledTimes(2);
67+
expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits');
68+
expect(headers.get).toHaveBeenCalledWith('Retry-After');
69+
});
70+
71+
it('allows for custom options to be passed in', async () => {
72+
const mockFetch = jest.fn(() =>
73+
Promise.resolve({
74+
headers: new Headers(),
75+
status: 200,
76+
text: () => Promise.resolve({}),
77+
}),
78+
) as unknown as FetchImpl;
79+
80+
const REQUEST_OPTIONS: RequestInit = {
81+
referrerPolicy: 'strict-origin',
82+
keepalive: true,
83+
referrer: 'http://example.org',
84+
};
85+
86+
const transport = makeNewFetchTransport(
87+
{ ...DEFAULT_FETCH_TRANSPORT_OPTIONS, requestOptions: REQUEST_OPTIONS },
88+
mockFetch,
89+
);
90+
91+
await transport.send(ERROR_ENVELOPE);
92+
expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, {
93+
body: serializeEnvelope(ERROR_ENVELOPE),
94+
method: 'POST',
95+
...REQUEST_OPTIONS,
96+
});
97+
});
98+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sentry.captureException(new Error('this is an error'));
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect } from '@playwright/test';
2+
import { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers';
6+
7+
sentryTest('should capture an error with the new fetch transport', async ({ getLocalTestPath, page }) => {
8+
const url = await getLocalTestPath({ testDir: __dirname });
9+
10+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
11+
12+
expect(eventData.exception?.values).toHaveLength(1);
13+
expect(eventData.exception?.values?.[0]).toMatchObject({
14+
type: 'Error',
15+
value: 'this is an error',
16+
mechanism: {
17+
type: 'generic',
18+
handled: true,
19+
},
20+
});
21+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
const transaction = Sentry.startTransaction({ name: 'test_transaction_1' });
2+
transaction.finish();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { expect } from '@playwright/test';
2+
import { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers';
6+
7+
sentryTest('should report a transaction with the new fetch transport', async ({ getLocalTestPath, page }) => {
8+
const url = await getLocalTestPath({ testDir: __dirname });
9+
const transaction = await getFirstSentryEnvelopeRequest<Event>(page, url);
10+
11+
expect(transaction.transaction).toBe('test_transaction_1');
12+
expect(transaction.spans).toBeDefined();
13+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from '@sentry/browser';
2+
// eslint-disable-next-line no-unused-vars
3+
import * as _ from '@sentry/tracing';
4+
5+
window.Sentry = Sentry;
6+
7+
Sentry.init({
8+
dsn: 'https://[email protected]/1337',
9+
_experiments: {
10+
newTransport: true,
11+
},
12+
tracesSampleRate: 1.0,
13+
});

0 commit comments

Comments
 (0)