diff --git a/packages/browser/src/backend.ts b/packages/browser/src/backend.ts index d6dfe5d25ddc..4cdef3d11047 100644 --- a/packages/browser/src/backend.ts +++ b/packages/browser/src/backend.ts @@ -3,7 +3,7 @@ import { Event, EventHint, Options, Severity, Transport, TransportOptions } from import { supportsFetch } from '@sentry/utils'; import { eventFromException, eventFromMessage } from './eventbuilder'; -import { FetchTransport, makeNewFetchTransport, XHRTransport } from './transports'; +import { FetchTransport, makeNewFetchTransport, makeNewXHRTransport, XHRTransport } from './transports'; /** * Configuration options for the Sentry Browser SDK. @@ -77,6 +77,11 @@ export class BrowserBackend extends BaseBackend { this._newTransport = makeNewFetchTransport({ requestOptions, url }); return new FetchTransport(transportOptions); } + + this._newTransport = makeNewXHRTransport({ + url, + headers: transportOptions.headers, + }); return new XHRTransport(transportOptions); } } diff --git a/packages/browser/src/transports/index.ts b/packages/browser/src/transports/index.ts index 8f938399e253..287e14e0ac50 100644 --- a/packages/browser/src/transports/index.ts +++ b/packages/browser/src/transports/index.ts @@ -3,3 +3,4 @@ export { FetchTransport } from './fetch'; export { XHRTransport } from './xhr'; export { makeNewFetchTransport } from './new-fetch'; +export { makeNewXHRTransport } from './new-xhr'; diff --git a/packages/browser/src/transports/new-xhr.ts b/packages/browser/src/transports/new-xhr.ts new file mode 100644 index 000000000000..cd19b1de0cd4 --- /dev/null +++ b/packages/browser/src/transports/new-xhr.ts @@ -0,0 +1,60 @@ +import { + BaseTransportOptions, + createTransport, + NewTransport, + TransportMakeRequestResponse, + TransportRequest, +} from '@sentry/core'; +import { SyncPromise } from '@sentry/utils'; + +/** + * The DONE ready state for XmlHttpRequest + * + * Defining it here as a constant b/c XMLHttpRequest.DONE is not always defined + * (e.g. during testing, it is `undefined`) + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/readyState} + */ +const XHR_READYSTATE_DONE = 4; + +export interface XHRTransportOptions extends BaseTransportOptions { + headers?: { [key: string]: string }; +} + +/** + * Creates a Transport that uses the XMLHttpRequest API to send events to Sentry. + */ +export function makeNewXHRTransport(options: XHRTransportOptions): NewTransport { + function makeRequest(request: TransportRequest): PromiseLike { + return new SyncPromise((resolve, _reject) => { + const xhr = new XMLHttpRequest(); + + xhr.onreadystatechange = (): void => { + if (xhr.readyState === XHR_READYSTATE_DONE) { + const response = { + body: xhr.response, + headers: { + 'x-sentry-rate-limits': xhr.getResponseHeader('X-Sentry-Rate-Limits'), + 'retry-after': xhr.getResponseHeader('Retry-After'), + }, + reason: xhr.statusText, + statusCode: xhr.status, + }; + resolve(response); + } + }; + + xhr.open('POST', options.url); + + for (const header in options.headers) { + if (Object.prototype.hasOwnProperty.call(options.headers, header)) { + xhr.setRequestHeader(header, options.headers[header]); + } + } + + xhr.send(request.body); + }); + } + + return createTransport({ bufferSize: options.bufferSize }, makeRequest); +} diff --git a/packages/browser/test/unit/transports/new-xhr.test.ts b/packages/browser/test/unit/transports/new-xhr.test.ts new file mode 100644 index 000000000000..5b3bbda313c7 --- /dev/null +++ b/packages/browser/test/unit/transports/new-xhr.test.ts @@ -0,0 +1,109 @@ +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; + +import { makeNewXHRTransport, XHRTransportOptions } from '../../../src/transports/new-xhr'; + +const DEFAULT_XHR_TRANSPORT_OPTIONS: XHRTransportOptions = { + 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, +]); + +function createXHRMock() { + const retryAfterSeconds = 10; + + const xhrMock: Partial = { + open: jest.fn(), + send: jest.fn(), + setRequestHeader: jest.fn(), + readyState: 4, + status: 200, + response: 'Hello World!', + onreadystatechange: () => {}, + getResponseHeader: jest.fn((header: string) => { + switch (header) { + case 'Retry-After': + return '10'; + case `${retryAfterSeconds}`: + return; + default: + return `${retryAfterSeconds}:error:scope`; + } + }), + }; + + // casting `window` as `any` because XMLHttpRequest is missing in Window (TS-only) + jest.spyOn(window as any, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest); + + return xhrMock; +} + +describe('NewXHRTransport', () => { + const xhrMock: Partial = createXHRMock(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('makes an XHR request to the given URL', async () => { + const transport = makeNewXHRTransport(DEFAULT_XHR_TRANSPORT_OPTIONS); + expect(xhrMock.open).toHaveBeenCalledTimes(0); + expect(xhrMock.setRequestHeader).toHaveBeenCalledTimes(0); + expect(xhrMock.send).toHaveBeenCalledTimes(0); + + await Promise.all([transport.send(ERROR_ENVELOPE), (xhrMock as XMLHttpRequest).onreadystatechange(null)]); + + expect(xhrMock.open).toHaveBeenCalledTimes(1); + expect(xhrMock.open).toHaveBeenCalledWith('POST', DEFAULT_XHR_TRANSPORT_OPTIONS.url); + expect(xhrMock.send).toHaveBeenCalledTimes(1); + expect(xhrMock.send).toHaveBeenCalledWith(serializeEnvelope(ERROR_ENVELOPE)); + }); + + it('returns the correct response', async () => { + const transport = makeNewXHRTransport(DEFAULT_XHR_TRANSPORT_OPTIONS); + + const [res] = await Promise.all([ + transport.send(ERROR_ENVELOPE), + (xhrMock as XMLHttpRequest).onreadystatechange(null), + ]); + + expect(res).toBeDefined(); + expect(res.status).toEqual('success'); + }); + + it('sets rate limit response headers', async () => { + const transport = makeNewXHRTransport(DEFAULT_XHR_TRANSPORT_OPTIONS); + + await Promise.all([transport.send(ERROR_ENVELOPE), (xhrMock as XMLHttpRequest).onreadystatechange(null)]); + + expect(xhrMock.getResponseHeader).toHaveBeenCalledTimes(2); + expect(xhrMock.getResponseHeader).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(xhrMock.getResponseHeader).toHaveBeenCalledWith('Retry-After'); + }); + + it('sets custom request headers', async () => { + const headers = { + referrerPolicy: 'strict-origin', + keepalive: 'true', + referrer: 'http://example.org', + }; + const options: XHRTransportOptions = { + ...DEFAULT_XHR_TRANSPORT_OPTIONS, + headers, + }; + + const transport = makeNewXHRTransport(options); + await Promise.all([transport.send(ERROR_ENVELOPE), (xhrMock as XMLHttpRequest).onreadystatechange(null)]); + + expect(xhrMock.setRequestHeader).toHaveBeenCalledTimes(3); + expect(xhrMock.setRequestHeader).toHaveBeenCalledWith('referrerPolicy', headers.referrerPolicy); + expect(xhrMock.setRequestHeader).toHaveBeenCalledWith('keepalive', headers.keepalive); + expect(xhrMock.setRequestHeader).toHaveBeenCalledWith('referrer', headers.referrer); + }); +});