Skip to content

feat(browser): Add new v7 XHR Transport #4803

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 29, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
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 };
Copy link
Member Author

@Lms24 Lms24 Mar 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided to go with {[key: string]: string} instead of Record as it is used that way in TransportOptions

export interface TransportOptions {
/** Sentry DSN */
dsn: DsnLike;
/** Define custom headers */
headers?: { [key: string]: string };

which are passed to makeNewXHRTransport. Seems to be more consistent that way IMHO

}

/**
* 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);
});
});