Skip to content

Commit

Permalink
feat(browser): Add new v7 XHR Transport (#4803)
Browse files Browse the repository at this point in the history
* add the new XHR Transport creation functionality. The function makeNewXHRTransport(...) creates a Transport that is based on the browser's XMLHttpRequest API. It is used as a fallback if the Fetch API is not available (IE11...). The creation function is similar to the new Fetch Transport creation introduced in #4765.

* in addition to the transport creation function, this PR also adds tests which verify the correct calls to the XMLHttpRequest API. Furthermore, the tests check for correct request/response header setting.
  • Loading branch information
Lms24 committed Mar 29, 2022
1 parent 0cf3014 commit fa58281
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 1 deletion.
7 changes: 6 additions & 1 deletion packages/browser/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -77,6 +77,11 @@ export class BrowserBackend extends BaseBackend<BrowserOptions> {
this._newTransport = makeNewFetchTransport({ requestOptions, url });
return new FetchTransport(transportOptions);
}

this._newTransport = makeNewXHRTransport({
url,
headers: transportOptions.headers,
});
return new XHRTransport(transportOptions);
}
}
1 change: 1 addition & 0 deletions packages/browser/src/transports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { FetchTransport } from './fetch';
export { XHRTransport } from './xhr';

export { makeNewFetchTransport } from './new-fetch';
export { makeNewXHRTransport } from './new-xhr';
60 changes: 60 additions & 0 deletions packages/browser/src/transports/new-xhr.ts
Original file line number Diff line number Diff line change
@@ -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<TransportMakeRequestResponse> {
return new SyncPromise<TransportMakeRequestResponse>((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);
}
109 changes: 109 additions & 0 deletions packages/browser/test/unit/transports/new-xhr.test.ts
Original file line number Diff line number Diff line change
@@ -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<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
]);

function createXHRMock() {
const retryAfterSeconds = 10;

const xhrMock: Partial<XMLHttpRequest> = {
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<XMLHttpRequest> = 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);
});
});

0 comments on commit fa58281

Please sign in to comment.