diff --git a/packages/browser/src/backend.ts b/packages/browser/src/backend.ts index 3ca50f2b2fe7..d6dfe5d25ddc 100644 --- a/packages/browser/src/backend.ts +++ b/packages/browser/src/backend.ts @@ -1,9 +1,9 @@ -import { BaseBackend } from '@sentry/core'; -import { Event, EventHint, Options, Severity, Transport } from '@sentry/types'; +import { BaseBackend, getEnvelopeEndpointWithUrlEncodedAuth, initAPIDetails } from '@sentry/core'; +import { Event, EventHint, Options, Severity, Transport, TransportOptions } from '@sentry/types'; import { supportsFetch } from '@sentry/utils'; import { eventFromException, eventFromMessage } from './eventbuilder'; -import { FetchTransport, XHRTransport } from './transports'; +import { FetchTransport, makeNewFetchTransport, XHRTransport } from './transports'; /** * Configuration options for the Sentry Browser SDK. @@ -58,7 +58,7 @@ export class BrowserBackend extends BaseBackend { return super._setupTransport(); } - const transportOptions = { + const transportOptions: TransportOptions = { ...this._options.transportOptions, dsn: this._options.dsn, tunnel: this._options.tunnel, @@ -66,10 +66,15 @@ export class BrowserBackend extends BaseBackend { _metadata: this._options._metadata, }; + const api = initAPIDetails(transportOptions.dsn, transportOptions._metadata, transportOptions.tunnel); + const url = getEnvelopeEndpointWithUrlEncodedAuth(api.dsn, api.tunnel); + if (this._options.transport) { return new this._options.transport(transportOptions); } if (supportsFetch()) { + const requestOptions: RequestInit = { ...transportOptions.fetchParameters }; + this._newTransport = makeNewFetchTransport({ requestOptions, url }); return new FetchTransport(transportOptions); } return new XHRTransport(transportOptions); diff --git a/packages/browser/src/transports/index.ts b/packages/browser/src/transports/index.ts index 65323f6c3744..8f938399e253 100644 --- a/packages/browser/src/transports/index.ts +++ b/packages/browser/src/transports/index.ts @@ -1,3 +1,5 @@ export { BaseTransport } from './base'; export { FetchTransport } from './fetch'; export { XHRTransport } from './xhr'; + +export { makeNewFetchTransport } from './new-fetch'; diff --git a/packages/browser/src/transports/new-fetch.ts b/packages/browser/src/transports/new-fetch.ts new file mode 100644 index 000000000000..47a85b517e77 --- /dev/null +++ b/packages/browser/src/transports/new-fetch.ts @@ -0,0 +1,44 @@ +import { + BaseTransportOptions, + createTransport, + NewTransport, + TransportMakeRequestResponse, + TransportRequest, +} from '@sentry/core'; + +import { FetchImpl, getNativeFetchImplementation } from './utils'; + +export interface FetchTransportOptions extends BaseTransportOptions { + requestOptions?: RequestInit; +} + +/** + * Creates a Transport that uses the Fetch API to send events to Sentry. + */ +export function makeNewFetchTransport( + options: FetchTransportOptions, + nativeFetch: FetchImpl = getNativeFetchImplementation(), +): NewTransport { + function makeRequest(request: TransportRequest): PromiseLike { + const requestOptions: RequestInit = { + body: request.body, + method: 'POST', + referrerPolicy: 'origin', + ...options.requestOptions, + }; + + return nativeFetch(options.url, requestOptions).then(response => { + return response.text().then(body => ({ + body, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + reason: response.statusText, + statusCode: response.status, + })); + }); + } + + return createTransport({ bufferSize: options.bufferSize }, makeRequest); +} diff --git a/packages/browser/test/unit/transports/new-fetch.test.ts b/packages/browser/test/unit/transports/new-fetch.test.ts new file mode 100644 index 000000000000..e1030be07204 --- /dev/null +++ b/packages/browser/test/unit/transports/new-fetch.test.ts @@ -0,0 +1,98 @@ +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; + +import { FetchTransportOptions, makeNewFetchTransport } from '../../../src/transports/new-fetch'; +import { FetchImpl } from '../../../src/transports/utils'; + +const DEFAULT_FETCH_TRANSPORT_OPTIONS: FetchTransportOptions = { + url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', +}; + +const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +class Headers { + headers: { [key: string]: string } = {}; + get(key: string) { + return this.headers[key] || null; + } + set(key: string, value: string) { + this.headers[key] = value; + } +} + +describe('NewFetchTransport', () => { + it('calls fetch with the given URL', async () => { + const mockFetch = jest.fn(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ) as unknown as FetchImpl; + const transport = makeNewFetchTransport(DEFAULT_FETCH_TRANSPORT_OPTIONS, mockFetch); + + expect(mockFetch).toHaveBeenCalledTimes(0); + const res = await transport.send(ERROR_ENVELOPE); + expect(mockFetch).toHaveBeenCalledTimes(1); + + expect(res.status).toBe('success'); + + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE), + method: 'POST', + referrerPolicy: 'origin', + }); + }); + + it('sets rate limit headers', async () => { + const headers = { + get: jest.fn(), + }; + + const mockFetch = jest.fn(() => + Promise.resolve({ + headers, + status: 200, + text: () => Promise.resolve({}), + }), + ) as unknown as FetchImpl; + const transport = makeNewFetchTransport(DEFAULT_FETCH_TRANSPORT_OPTIONS, mockFetch); + + expect(headers.get).toHaveBeenCalledTimes(0); + await transport.send(ERROR_ENVELOPE); + + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('allows for custom options to be passed in', async () => { + const mockFetch = jest.fn(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ) as unknown as FetchImpl; + + const REQUEST_OPTIONS: RequestInit = { + referrerPolicy: 'strict-origin', + keepalive: true, + referrer: 'http://example.org', + }; + + const transport = makeNewFetchTransport( + { ...DEFAULT_FETCH_TRANSPORT_OPTIONS, requestOptions: REQUEST_OPTIONS }, + mockFetch, + ); + + await transport.send(ERROR_ENVELOPE); + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE), + method: 'POST', + ...REQUEST_OPTIONS, + }); + }); +}); diff --git a/packages/integration-tests/suites/new-transports/fetch-captureException/subject.js b/packages/integration-tests/suites/new-transports/fetch-captureException/subject.js new file mode 100644 index 000000000000..9cc217bdb087 --- /dev/null +++ b/packages/integration-tests/suites/new-transports/fetch-captureException/subject.js @@ -0,0 +1 @@ +Sentry.captureException(new Error('this is an error')); diff --git a/packages/integration-tests/suites/new-transports/fetch-captureException/test.ts b/packages/integration-tests/suites/new-transports/fetch-captureException/test.ts new file mode 100644 index 000000000000..cb92e50e2dc5 --- /dev/null +++ b/packages/integration-tests/suites/new-transports/fetch-captureException/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import { Event } from '@sentry/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; + +sentryTest('should capture an error with the new fetch transport', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'this is an error', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/integration-tests/suites/new-transports/fetch-startTransaction/subject.js b/packages/integration-tests/suites/new-transports/fetch-startTransaction/subject.js new file mode 100644 index 000000000000..78c7c33c654c --- /dev/null +++ b/packages/integration-tests/suites/new-transports/fetch-startTransaction/subject.js @@ -0,0 +1,2 @@ +const transaction = Sentry.startTransaction({ name: 'test_transaction_1' }); +transaction.finish(); diff --git a/packages/integration-tests/suites/new-transports/fetch-startTransaction/test.ts b/packages/integration-tests/suites/new-transports/fetch-startTransaction/test.ts new file mode 100644 index 000000000000..8daef2e06b54 --- /dev/null +++ b/packages/integration-tests/suites/new-transports/fetch-startTransaction/test.ts @@ -0,0 +1,13 @@ +import { expect } from '@playwright/test'; +import { Event } from '@sentry/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; + +sentryTest('should report a transaction with the new fetch transport', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + const transaction = await getFirstSentryEnvelopeRequest(page, url); + + expect(transaction.transaction).toBe('test_transaction_1'); + expect(transaction.spans).toBeDefined(); +}); diff --git a/packages/integration-tests/suites/new-transports/init.js b/packages/integration-tests/suites/new-transports/init.js new file mode 100644 index 000000000000..6cc8110c0475 --- /dev/null +++ b/packages/integration-tests/suites/new-transports/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; +// eslint-disable-next-line no-unused-vars +import * as _ from '@sentry/tracing'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + _experiments: { + newTransport: true, + }, + tracesSampleRate: 1.0, +});