diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 9b138864515a..184893f15cf0 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -14,7 +14,7 @@ import { IS_DEBUG_BUILD } from './flags'; import { ReportDialogOptions, wrap as internalWrap } from './helpers'; import { Breadcrumbs, Dedupe, GlobalHandlers, LinkedErrors, TryCatch, UserAgent } from './integrations'; import { defaultStackParsers } from './stack-parsers'; -import { makeNewFetchTransport, makeNewXHRTransport } from './transports'; +import { makeFetchTransport, makeXHRTransport } from './transports'; export const defaultIntegrations = [ new CoreIntegrations.InboundFilters(), @@ -106,7 +106,7 @@ export function init(options: BrowserOptions = {}): void { ...options, stackParser: stackParserFromOptions(options.stackParser || defaultStackParsers), integrations: getIntegrationsToSetup(options), - transport: options.transport || (supportsFetch() ? makeNewFetchTransport : makeNewXHRTransport), + transport: options.transport || (supportsFetch() ? makeFetchTransport : makeXHRTransport), }; initAndBind(BrowserClient, clientOptions); diff --git a/packages/browser/src/transports/base.ts b/packages/browser/src/transports/base.ts deleted file mode 100644 index c7785b4eb7c5..000000000000 --- a/packages/browser/src/transports/base.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { - APIDetails, - eventToSentryRequest, - getEnvelopeEndpointWithUrlEncodedAuth, - getStoreEndpointWithUrlEncodedAuth, - initAPIDetails, - sessionToSentryRequest, -} from '@sentry/core'; -import { - ClientReport, - Event, - Outcome, - Response as SentryResponse, - SentryRequest, - SentryRequestType, - Session, - Transport, - TransportOptions, -} from '@sentry/types'; -import { - createClientReportEnvelope, - disabledUntil, - dsnToString, - eventStatusFromHttpCode, - getGlobalObject, - isRateLimited, - logger, - makePromiseBuffer, - PromiseBuffer, - RateLimits, - serializeEnvelope, - updateRateLimits, -} from '@sentry/utils'; - -import { IS_DEBUG_BUILD } from '../flags'; -import { sendReport } from './utils'; - -function requestTypeToCategory(ty: SentryRequestType): string { - const tyStr = ty as string; - return tyStr === 'event' ? 'error' : tyStr; -} - -const global = getGlobalObject(); - -/** Base Transport class implementation */ -export abstract class BaseTransport implements Transport { - /** - * @deprecated - */ - public url: string; - - /** Helper to get Sentry API endpoints. */ - protected readonly _api: APIDetails; - - /** A simple buffer holding all requests. */ - protected readonly _buffer: PromiseBuffer = makePromiseBuffer(30); - - /** Locks transport after receiving rate limits in a response */ - protected _rateLimits: RateLimits = {}; - - protected _outcomes: { [key: string]: number } = {}; - - public constructor(public options: TransportOptions) { - this._api = initAPIDetails(options.dsn, options._metadata, options.tunnel); - // eslint-disable-next-line deprecation/deprecation - this.url = getStoreEndpointWithUrlEncodedAuth(this._api.dsn); - - if (this.options.sendClientReports && global.document) { - global.document.addEventListener('visibilitychange', () => { - if (global.document.visibilityState === 'hidden') { - this._flushOutcomes(); - } - }); - } - } - - /** - * @inheritDoc - */ - public sendEvent(event: Event): PromiseLike { - return this._sendRequest(eventToSentryRequest(event, this._api), event); - } - - /** - * @inheritDoc - */ - public sendSession(session: Session): PromiseLike { - return this._sendRequest(sessionToSentryRequest(session, this._api), session); - } - - /** - * @inheritDoc - */ - public close(timeout?: number): PromiseLike { - return this._buffer.drain(timeout); - } - - /** - * @inheritDoc - */ - public recordLostEvent(reason: Outcome, category: SentryRequestType): void { - if (!this.options.sendClientReports) { - return; - } - // We want to track each category (event, transaction, session) separately - // but still keep the distinction between different type of outcomes. - // We could use nested maps, but it's much easier to read and type this way. - // A correct type for map-based implementation if we want to go that route - // would be `Partial>>>` - const key = `${requestTypeToCategory(category)}:${reason}`; - IS_DEBUG_BUILD && logger.log(`Adding outcome: ${key}`); - this._outcomes[key] = (this._outcomes[key] ?? 0) + 1; - } - - /** - * Send outcomes as an envelope - */ - protected _flushOutcomes(): void { - if (!this.options.sendClientReports) { - return; - } - - const outcomes = this._outcomes; - this._outcomes = {}; - - // Nothing to send - if (!Object.keys(outcomes).length) { - IS_DEBUG_BUILD && logger.log('No outcomes to flush'); - return; - } - - IS_DEBUG_BUILD && logger.log(`Flushing outcomes:\n${JSON.stringify(outcomes, null, 2)}`); - - const url = getEnvelopeEndpointWithUrlEncodedAuth(this._api.dsn, this._api.tunnel); - - const discardedEvents = Object.keys(outcomes).map(key => { - const [category, reason] = key.split(':'); - return { - reason, - category, - quantity: outcomes[key], - }; - // TODO: Improve types on discarded_events to get rid of cast - }) as ClientReport['discarded_events']; - const envelope = createClientReportEnvelope(discardedEvents, this._api.tunnel && dsnToString(this._api.dsn)); - - try { - sendReport(url, serializeEnvelope(envelope)); - } catch (e) { - IS_DEBUG_BUILD && logger.error(e); - } - } - - /** - * Handle Sentry repsonse for promise-based transports. - */ - protected _handleResponse({ - requestType, - response, - headers, - resolve, - reject, - }: { - requestType: SentryRequestType; - response: Response | XMLHttpRequest; - headers: Record; - resolve: (value?: SentryResponse | PromiseLike | null | undefined) => void; - reject: (reason?: unknown) => void; - }): void { - const status = eventStatusFromHttpCode(response.status); - - this._rateLimits = updateRateLimits(this._rateLimits, headers); - // eslint-disable-next-line deprecation/deprecation - if (this._isRateLimited(requestType)) { - IS_DEBUG_BUILD && - // eslint-disable-next-line deprecation/deprecation - logger.warn(`Too many ${requestType} requests, backing off until: ${this._disabledUntil(requestType)}`); - } - - if (status === 'success') { - resolve({ status }); - return; - } - - reject(response); - } - - /** - * Gets the time that given category is disabled until for rate limiting - * - * @deprecated Please use `disabledUntil` from @sentry/utils - */ - protected _disabledUntil(requestType: SentryRequestType): Date { - const category = requestTypeToCategory(requestType); - return new Date(disabledUntil(this._rateLimits, category)); - } - - /** - * Checks if a category is rate limited - * - * @deprecated Please use `isRateLimited` from @sentry/utils - */ - protected _isRateLimited(requestType: SentryRequestType): boolean { - const category = requestTypeToCategory(requestType); - return isRateLimited(this._rateLimits, category); - } - - protected abstract _sendRequest( - sentryRequest: SentryRequest, - originalPayload: Event | Session, - ): PromiseLike; -} diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index cddf1b53c1ae..f1aa6709da80 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -1,86 +1,39 @@ -import { Event, Response, SentryRequest, Session, TransportOptions } from '@sentry/types'; -import { SentryError, supportsReferrerPolicy, SyncPromise } from '@sentry/utils'; +import { createTransport } from '@sentry/core'; +import { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; -import { BaseTransport } from './base'; import { FetchImpl, getNativeFetchImplementation } from './utils'; -/** `fetch` based transport */ -export class FetchTransport extends BaseTransport { - /** - * Fetch API reference which always points to native browser implementation. - */ - private _fetch: typeof fetch; - - public constructor(options: TransportOptions, fetchImpl: FetchImpl = getNativeFetchImplementation()) { - super(options); - this._fetch = fetchImpl; - } - - /** - * @param sentryRequest Prepared SentryRequest to be delivered - * @param originalPayload Original payload used to create SentryRequest - */ - protected _sendRequest(sentryRequest: SentryRequest, originalPayload: Event | Session): PromiseLike { - // eslint-disable-next-line deprecation/deprecation - if (this._isRateLimited(sentryRequest.type)) { - this.recordLostEvent('ratelimit_backoff', sentryRequest.type); - - return Promise.reject({ - event: originalPayload, - type: sentryRequest.type, - // eslint-disable-next-line deprecation/deprecation - reason: `Transport for ${sentryRequest.type} requests locked till ${this._disabledUntil( - sentryRequest.type, - )} due to too many requests.`, - status: 429, - }); - } +export interface FetchTransportOptions extends BaseTransportOptions { + requestOptions?: RequestInit; +} - const options: RequestInit = { - body: sentryRequest.body, +/** + * Creates a Transport that uses the Fetch API to send events to Sentry. + */ +export function makeFetchTransport( + options: FetchTransportOptions, + nativeFetch: FetchImpl = getNativeFetchImplementation(), +): Transport { + function makeRequest(request: TransportRequest): PromiseLike { + const requestOptions: RequestInit = { + body: request.body, method: 'POST', - // Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default' - // (see https://caniuse.com/#feat=referrer-policy), - // it doesn't. And it throws an exception instead of ignoring this parameter... - // REF: https://github.com/getsentry/raven-js/issues/1233 - referrerPolicy: (supportsReferrerPolicy() ? 'origin' : '') as ReferrerPolicy, + referrerPolicy: 'origin', + ...options.requestOptions, }; - if (this.options.fetchParameters !== undefined) { - Object.assign(options, this.options.fetchParameters); - } - if (this.options.headers !== undefined) { - options.headers = this.options.headers; - } - return this._buffer - .add( - () => - new SyncPromise((resolve, reject) => { - void this._fetch(sentryRequest.url, options) - .then(response => { - const headers = { - 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), - 'retry-after': response.headers.get('Retry-After'), - }; - this._handleResponse({ - requestType: sentryRequest.type, - response, - headers, - resolve, - reject, - }); - }) - .catch(reject); - }), - ) - .then(undefined, reason => { - // It's either buffer rejection or any other xhr/fetch error, which are treated as NetworkError. - if (reason instanceof SentryError) { - this.recordLostEvent('queue_overflow', sentryRequest.type); - } else { - this.recordLostEvent('network_error', sentryRequest.type); - } - throw reason; - }); + 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/src/transports/index.ts b/packages/browser/src/transports/index.ts index 287e14e0ac50..c30287e3e616 100644 --- a/packages/browser/src/transports/index.ts +++ b/packages/browser/src/transports/index.ts @@ -1,6 +1,2 @@ -export { BaseTransport } from './base'; -export { FetchTransport } from './fetch'; -export { XHRTransport } from './xhr'; - -export { makeNewFetchTransport } from './new-fetch'; -export { makeNewXHRTransport } from './new-xhr'; +export { makeFetchTransport } from './fetch'; +export { makeXHRTransport } from './xhr'; diff --git a/packages/browser/src/transports/new-fetch.ts b/packages/browser/src/transports/new-fetch.ts deleted file mode 100644 index 9a9d7b14ae19..000000000000 --- a/packages/browser/src/transports/new-fetch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createTransport } from '@sentry/core'; -import { BaseTransportOptions, NewTransport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; - -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/src/transports/new-xhr.ts b/packages/browser/src/transports/new-xhr.ts deleted file mode 100644 index d45a0019914c..000000000000 --- a/packages/browser/src/transports/new-xhr.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createTransport } from '@sentry/core'; -import { BaseTransportOptions, NewTransport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; -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/src/transports/xhr.ts b/packages/browser/src/transports/xhr.ts index 5da7de258bf6..4b36e348de73 100644 --- a/packages/browser/src/transports/xhr.ts +++ b/packages/browser/src/transports/xhr.ts @@ -1,63 +1,55 @@ -import { Event, Response, SentryRequest, Session } from '@sentry/types'; -import { SentryError, SyncPromise } from '@sentry/utils'; - -import { BaseTransport } from './base'; - -/** `XHR` based transport */ -export class XHRTransport extends BaseTransport { - /** - * @param sentryRequest Prepared SentryRequest to be delivered - * @param originalPayload Original payload used to create SentryRequest - */ - protected _sendRequest(sentryRequest: SentryRequest, originalPayload: Event | Session): PromiseLike { - // eslint-disable-next-line deprecation/deprecation - if (this._isRateLimited(sentryRequest.type)) { - this.recordLostEvent('ratelimit_backoff', sentryRequest.type); - - return Promise.reject({ - event: originalPayload, - type: sentryRequest.type, - // eslint-disable-next-line deprecation/deprecation - reason: `Transport for ${sentryRequest.type} requests locked till ${this._disabledUntil( - sentryRequest.type, - )} due to too many requests.`, - status: 429, - }); - } - - return this._buffer - .add( - () => - new SyncPromise((resolve, reject) => { - const request = new XMLHttpRequest(); - - request.onreadystatechange = (): void => { - if (request.readyState === 4) { - const headers = { - 'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'), - 'retry-after': request.getResponseHeader('Retry-After'), - }; - this._handleResponse({ requestType: sentryRequest.type, response: request, headers, resolve, reject }); - } - }; - - request.open('POST', sentryRequest.url); - for (const header in this.options.headers) { - if (Object.prototype.hasOwnProperty.call(this.options.headers, header)) { - request.setRequestHeader(header, this.options.headers[header]); - } - } - request.send(sentryRequest.body); - }), - ) - .then(undefined, reason => { - // It's either buffer rejection or any other xhr/fetch error, which are treated as NetworkError. - if (reason instanceof SentryError) { - this.recordLostEvent('queue_overflow', sentryRequest.type); - } else { - this.recordLostEvent('network_error', sentryRequest.type); +import { createTransport } from '@sentry/core'; +import { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; +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 makeXHRTransport(options: XHRTransportOptions): Transport { + 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); } - throw reason; - }); + }; + + 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/base.test.ts b/packages/browser/test/unit/transports/base.test.ts deleted file mode 100644 index 75894049c1ca..000000000000 --- a/packages/browser/test/unit/transports/base.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { BaseTransport } from '../../../src/transports/base'; - -const testDsn = 'https://123@sentry.io/42'; -const envelopeEndpoint = 'https://sentry.io/api/42/envelope/?sentry_key=123&sentry_version=7'; - -// @ts-ignore We're purposely not implementing the methods of the abstract `BaseTransport` class in order to be able to -// assert on what the class provides and what it leaves to the concrete class to implement -class SimpleTransport extends BaseTransport {} - -// TODO(v7): Re-enable these tests with client reports -describe.skip('BaseTransport', () => { - describe('Client Reports', () => { - const sendBeaconSpy = jest.fn(); - let visibilityState: string; - - beforeAll(() => { - navigator.sendBeacon = sendBeaconSpy; - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return visibilityState; - }, - }); - jest.spyOn(Date, 'now').mockImplementation(() => 12345); - }); - - beforeEach(() => { - sendBeaconSpy.mockClear(); - }); - - it('attaches visibilitychange handler if sendClientReport is set to true', () => { - const eventListenerSpy = jest.spyOn(document, 'addEventListener'); - new SimpleTransport({ dsn: testDsn, sendClientReports: true }); - expect(eventListenerSpy.mock.calls[0][0]).toBe('visibilitychange'); - eventListenerSpy.mockRestore(); - }); - - it('doesnt attach visibilitychange handler if sendClientReport is set to false', () => { - const eventListenerSpy = jest.spyOn(document, 'addEventListener'); - new SimpleTransport({ dsn: testDsn, sendClientReports: false }); - expect(eventListenerSpy).not.toHaveBeenCalled(); - eventListenerSpy.mockRestore(); - }); - - it('sends beacon request when there are outcomes captured and visibility changed to `hidden`', () => { - const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true }); - - transport.recordLostEvent('before_send', 'event'); - - visibilityState = 'hidden'; - document.dispatchEvent(new Event('visibilitychange')); - - const outcomes = [{ reason: 'before_send', category: 'error', quantity: 1 }]; - - expect(sendBeaconSpy).toHaveBeenCalledWith( - envelopeEndpoint, - `{}\n{"type":"client_report"}\n{"timestamp":12.345,"discarded_events":${JSON.stringify(outcomes)}}`, - ); - }); - - it('doesnt send beacon request when there are outcomes captured, but visibility state did not change to `hidden`', () => { - const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true }); - transport.recordLostEvent('before_send', 'event'); - - visibilityState = 'visible'; - document.dispatchEvent(new Event('visibilitychange')); - - expect(sendBeaconSpy).not.toHaveBeenCalled(); - }); - - it('correctly serializes request with different categories/reasons pairs', () => { - const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true }); - - transport.recordLostEvent('before_send', 'event'); - transport.recordLostEvent('before_send', 'event'); - transport.recordLostEvent('sample_rate', 'transaction'); - transport.recordLostEvent('network_error', 'session'); - transport.recordLostEvent('network_error', 'session'); - transport.recordLostEvent('ratelimit_backoff', 'event'); - - visibilityState = 'hidden'; - document.dispatchEvent(new Event('visibilitychange')); - - const outcomes = [ - { reason: 'before_send', category: 'error', quantity: 2 }, - { reason: 'sample_rate', category: 'transaction', quantity: 1 }, - { reason: 'network_error', category: 'session', quantity: 2 }, - { reason: 'ratelimit_backoff', category: 'error', quantity: 1 }, - ]; - - expect(sendBeaconSpy).toHaveBeenCalledWith( - envelopeEndpoint, - `{}\n{"type":"client_report"}\n{"timestamp":12.345,"discarded_events":${JSON.stringify(outcomes)}}`, - ); - }); - - it('attaches DSN to envelope header if tunnel is configured', () => { - const tunnel = 'https://hello.com/world'; - const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true, tunnel }); - - transport.recordLostEvent('before_send', 'event'); - - visibilityState = 'hidden'; - document.dispatchEvent(new Event('visibilitychange')); - - const outcomes = [{ reason: 'before_send', category: 'error', quantity: 1 }]; - - expect(sendBeaconSpy).toHaveBeenCalledWith( - tunnel, - `{"dsn":"${testDsn}"}\n{"type":"client_report"}\n{"timestamp":12.345,"discarded_events":${JSON.stringify( - outcomes, - )}}`, - ); - }); - }); - - it('doesnt provide sendEvent() implementation', async () => { - expect.assertions(1); - const transport = new SimpleTransport({ dsn: testDsn }); - - try { - await transport.sendEvent({}); - } catch (e) { - expect(e).toBeDefined(); - } - }); - - it('has correct endpoint url', () => { - const transport = new SimpleTransport({ dsn: testDsn }); - // eslint-disable-next-line deprecation/deprecation - expect(transport.url).toBe('https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7'); - }); -}); diff --git a/packages/browser/test/unit/transports/fetch.test.ts b/packages/browser/test/unit/transports/fetch.test.ts index f7af4e38349c..64ea580c1845 100644 --- a/packages/browser/test/unit/transports/fetch.test.ts +++ b/packages/browser/test/unit/transports/fetch.test.ts @@ -1,515 +1,98 @@ -import { SentryError } from '@sentry/utils'; +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; -import { Event, Response, Transports } from '../../../src'; +import { FetchTransportOptions, makeFetchTransport } from '../../../src/transports/fetch'; +import { FetchImpl } from '../../../src/transports/utils'; -const testDsn = 'https://123@sentry.io/42'; -const storeUrl = 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7'; -const tunnel = 'https://hello.com/world'; -const eventPayload: Event = { - event_id: '1337', +const DEFAULT_FETCH_TRANSPORT_OPTIONS: FetchTransportOptions = { + url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', }; -const transactionPayload: Event = { - event_id: '42', - type: 'transaction', -}; - -const fetch = jest.fn(); -let transport: Transports.BaseTransport; - -// eslint-disable-next-line no-var -declare var window: any; -jest.mock('@sentry/utils', () => { - return { - ...jest.requireActual('@sentry/utils'), - supportsReferrerPolicy(): boolean { - return true; - }, - }; -}); - -describe('FetchTransport', () => { - beforeEach(() => { - window.fetch = fetch; - window.Headers = class Headers { - headers: { [key: string]: string } = {}; - get(key: string) { - return this.headers[key]; - } - set(key: string, value: string) { - this.headers[key] = value; - } - }; - transport = new Transports.FetchTransport({ dsn: testDsn }, window.fetch); +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 = makeFetchTransport(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', + }); }); - afterEach(() => { - fetch.mockRestore(); - }); + it('sets rate limit headers', async () => { + const headers = { + get: jest.fn(), + }; - it('inherits composeEndpointUrl() implementation', () => { - // eslint-disable-next-line deprecation/deprecation - expect(transport.url).toBe(storeUrl); + const mockFetch = jest.fn(() => + Promise.resolve({ + headers, + status: 200, + text: () => Promise.resolve({}), + }), + ) as unknown as FetchImpl; + const transport = makeFetchTransport(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'); }); - describe('sendEvent()', () => { - it('sends a request to Sentry servers', async () => { - const response = { status: 200, headers: new Headers() }; - - window.fetch.mockImplementation(() => Promise.resolve(response)); - - const res = await transport.sendEvent(eventPayload); - - expect((res as Response).status).toBe('success'); - expect(fetch).toHaveBeenCalledWith(storeUrl, { - body: JSON.stringify(eventPayload), - method: 'POST', - referrerPolicy: 'origin', - }); - }); - - it('sends a request to tunnel if configured', async () => { - transport = new Transports.FetchTransport({ dsn: testDsn, tunnel }, window.fetch); - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() })); - - await transport.sendEvent(eventPayload); - - expect(fetch.mock.calls[0][0]).toBe(tunnel); - }); - - it('rejects with non-200 status code', async () => { - const response = { status: 403, headers: new Headers() }; - - window.fetch.mockImplementation(() => Promise.resolve(response)); - - try { - await transport.sendEvent(eventPayload); - } catch (res) { - expect((res as Response).status).toBe(403); - expect(fetch).toHaveBeenCalledWith(storeUrl, { - body: JSON.stringify(eventPayload), - method: 'POST', - referrerPolicy: 'origin', - }); - } - }); - - it('pass the error to rejection when fetch fails', async () => { - const response = { status: 403, headers: new Headers() }; - - window.fetch.mockImplementation(() => Promise.reject(response)); - - try { - await transport.sendEvent(eventPayload); - } catch (res) { - expect(res).toBe(response); - } - }); - - it('should record dropped event when fetch fails', async () => { - const response = { status: 403, headers: new Headers() }; - - window.fetch.mockImplementation(() => Promise.reject(response)); - - const spy = jest.spyOn(transport, 'recordLostEvent'); - - try { - await transport.sendEvent(eventPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('network_error', 'event'); - } - }); - - it('should record dropped event when queue buffer overflows', async () => { - // @ts-ignore private method - jest.spyOn(transport._buffer, 'add').mockRejectedValue(new SentryError('Buffer Full')); - const spy = jest.spyOn(transport, 'recordLostEvent'); - - try { - await transport.sendEvent(transactionPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('queue_overflow', 'transaction'); - } - }); - - it('passes in headers', async () => { - transport = new Transports.FetchTransport( - { - dsn: testDsn, - headers: { - Accept: 'application/json', - }, - }, - window.fetch, - ); - const response = { status: 200, headers: new Headers() }; - - window.fetch.mockImplementation(() => Promise.resolve(response)); - - const res = await transport.sendEvent(eventPayload); - - expect((res as Response).status).toBe('success'); - expect(fetch).toHaveBeenCalledWith(storeUrl, { - body: JSON.stringify(eventPayload), - headers: { - Accept: 'application/json', - }, - method: 'POST', - referrerPolicy: 'origin', - }); - }); - - it('passes in fetch parameters', async () => { - transport = new Transports.FetchTransport( - { - dsn: testDsn, - fetchParameters: { - credentials: 'include', - }, - }, - window.fetch, - ); - const response = { status: 200, headers: new Headers() }; - - window.fetch.mockImplementation(() => Promise.resolve(response)); - - const res = await transport.sendEvent(eventPayload); - - expect((res as Response).status).toBe('success'); - expect(fetch).toHaveBeenCalledWith(storeUrl, { - body: JSON.stringify(eventPayload), - credentials: 'include', - method: 'POST', - referrerPolicy: 'origin', - }); - }); - - describe('Rate-limiting', () => { - it('back-off using Retry-After header', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - jest - .spyOn(Date, 'now') - // 1st event - updateRateLimits - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - false - .mockImplementationOnce(() => afterLimit) - // 3rd event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - const headers = new Headers(); - headers.set('Retry-After', `${retryAfterSeconds}`); - window.fetch.mockImplementation(() => Promise.resolve({ status: 429, headers })); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(fetch).toHaveBeenCalled(); - } - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalled(); - } - - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() })); - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it('back-off using X-Sentry-Rate-Limits with single category', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - false (different category) - .mockImplementationOnce(() => withinLimit) - // 2nd event - _handleRateLimit - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - true - .mockImplementationOnce(() => withinLimit) - // 4th event - _isRateLimited - false - .mockImplementationOnce(() => afterLimit) - // 4th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - const headers = new Headers(); - headers.set('X-Sentry-Rate-Limits', `${retryAfterSeconds}:error:scope`); - window.fetch.mockImplementation(() => Promise.resolve({ status: 429, headers })); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(fetch).toHaveBeenCalled(); - } - - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() })); - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(2); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalledTimes(2); - } - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it('back-off using X-Sentry-Rate-Limits with multiple categories', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - updateRateLimits - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true (event category) - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - true (transaction category) - .mockImplementationOnce(() => withinLimit) - // 4th event - _isRateLimited - false (event category) - .mockImplementationOnce(() => afterLimit) - // 4th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit) - // 5th event - _isRateLimited - false (transaction category) - .mockImplementationOnce(() => afterLimit) - // 5th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - const headers = new Headers(); - headers.set('X-Sentry-Rate-Limits', `${retryAfterSeconds}:error;transaction:scope`); - window.fetch.mockImplementation(() => Promise.resolve({ status: 429, headers })); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(fetch).toHaveBeenCalled(); - } - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalled(); - } - - try { - await transport.sendEvent(transactionPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for transaction requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalled(); - } - - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() })); - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(2); - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it('back-off using X-Sentry-Rate-Limits with missing categories should lock them all', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true (event category) - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - true (transaction category) - .mockImplementationOnce(() => withinLimit) - // 4th event - _isRateLimited - false (event category) - .mockImplementationOnce(() => afterLimit) - // 4th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit) - // 5th event - _isRateLimited - false (transaction category) - .mockImplementationOnce(() => afterLimit) - // 5th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - const headers = new Headers(); - headers.set('X-Sentry-Rate-Limits', `${retryAfterSeconds}::scope`); - window.fetch.mockImplementation(() => Promise.resolve({ status: 429, headers })); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(fetch).toHaveBeenCalled(); - } - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalled(); - } - - try { - await transport.sendEvent(transactionPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for transaction requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalled(); - } - - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() })); - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(2); - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it('back-off using X-Sentry-Rate-Limits should also trigger for 200 responses', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - false - .mockImplementationOnce(() => afterLimit) - // 3rd event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - const headers = new Headers(); - headers.set('X-Sentry-Rate-Limits', `${retryAfterSeconds}:error;transaction:scope`); - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers })); - - let eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(fetch).toHaveBeenCalled(); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(fetch).toHaveBeenCalled(); - } - - window.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() })); - - eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it('should record dropped event', async () => { - // @ts-ignore private method - jest.spyOn(transport, '_isRateLimited').mockReturnValue(true); - - const spy = jest.spyOn(transport, 'recordLostEvent'); + 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', + }; - try { - await transport.sendEvent(eventPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('ratelimit_backoff', 'event'); - } + const transport = makeFetchTransport( + { ...DEFAULT_FETCH_TRANSPORT_OPTIONS, requestOptions: REQUEST_OPTIONS }, + mockFetch, + ); - try { - await transport.sendEvent(transactionPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('ratelimit_backoff', 'transaction'); - } - }); + 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/browser/test/unit/transports/new-fetch.test.ts b/packages/browser/test/unit/transports/new-fetch.test.ts deleted file mode 100644 index e1030be07204..000000000000 --- a/packages/browser/test/unit/transports/new-fetch.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -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/browser/test/unit/transports/new-xhr.test.ts b/packages/browser/test/unit/transports/new-xhr.test.ts deleted file mode 100644 index 603b0f6037dc..000000000000 --- a/packages/browser/test/unit/transports/new-xhr.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 null; - 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!({} as Event)]); - - 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!({} as Event), - ]); - - 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!({} as Event)]); - - 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!({} as Event)]); - - 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); - }); -}); diff --git a/packages/browser/test/unit/transports/xhr.test.ts b/packages/browser/test/unit/transports/xhr.test.ts index 2a0f43d89815..3ec634360177 100644 --- a/packages/browser/test/unit/transports/xhr.test.ts +++ b/packages/browser/test/unit/transports/xhr.test.ts @@ -1,441 +1,109 @@ -import { SentryError } from '@sentry/utils'; -import { fakeServer, SinonFakeServer } from 'sinon'; +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; -import { Event, Response, Transports } from '../../../src'; +import { makeXHRTransport, XHRTransportOptions } from '../../../src/transports/xhr'; -const testDsn = 'https://123@sentry.io/42'; -const storeUrl = 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7'; -const envelopeUrl = 'https://sentry.io/api/42/envelope/?sentry_key=123&sentry_version=7'; -const tunnel = 'https://hello.com/world'; -const eventPayload: Event = { - event_id: '1337', +const DEFAULT_XHR_TRANSPORT_OPTIONS: XHRTransportOptions = { + url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', }; -const transactionPayload: Event = { - event_id: '42', - type: 'transaction', -}; - -let server: SinonFakeServer; -let transport: Transports.BaseTransport; - -describe('XHRTransport', () => { - beforeEach(() => { - server = fakeServer.create(); - server.respondImmediately = true; - transport = new Transports.XHRTransport({ dsn: testDsn }); - }); - - afterEach(() => { - server.restore(); - }); - - it('inherits composeEndpointUrl() implementation', () => { - // eslint-disable-next-line deprecation/deprecation - expect(transport.url).toBe(storeUrl); - }); - - describe('sendEvent()', () => { - it('sends a request to Sentry servers', async () => { - server.respondWith('POST', storeUrl, [200, {}, '']); - - const res = await transport.sendEvent(eventPayload); - - expect((res as Response).status).toBe('success'); - const request = server.requests[0]; - expect(server.requests.length).toBe(1); - expect(request.method).toBe('POST'); - expect(JSON.parse(request.requestBody)).toEqual(eventPayload); - }); - - it('sends a request to tunnel if configured', async () => { - transport = new Transports.XHRTransport({ dsn: testDsn, tunnel }); - server.respondWith('POST', tunnel, [200, {}, '']); - - await transport.sendEvent(eventPayload); - - expect(server.requests[0].url).toBe(tunnel); - }); - - it('rejects with non-200 status code', async () => { - server.respondWith('POST', storeUrl, [403, {}, '']); - try { - await transport.sendEvent(eventPayload); - } catch (res) { - expect((res as Response).status).toBe(403); - const request = server.requests[0]; - expect(server.requests.length).toBe(1); - expect(request.method).toBe('POST'); - expect(JSON.parse(request.requestBody)).toEqual(eventPayload); +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 null; + default: + return `${retryAfterSeconds}:error:scope`; } - }); - - it('should record dropped event when request fails', async () => { - server.respondWith('POST', storeUrl, [403, {}, '']); - - const spy = jest.spyOn(transport, 'recordLostEvent'); - - try { - await transport.sendEvent(eventPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('network_error', 'event'); - } - }); - - it('should record dropped event when queue buffer overflows', async () => { - // @ts-ignore private method - jest.spyOn(transport._buffer, 'add').mockRejectedValue(new SentryError('Buffer Full')); - const spy = jest.spyOn(transport, 'recordLostEvent'); - - try { - await transport.sendEvent(transactionPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('queue_overflow', 'transaction'); - } - }); - - it('passes in headers', async () => { - transport = new Transports.XHRTransport({ - dsn: testDsn, - headers: { - Accept: 'application/json', - }, - }); - - server.respondWith('POST', storeUrl, [200, {}, '']); - const res = await transport.sendEvent(eventPayload); - const request = server.requests[0]; - - expect((res as Response).status).toBe('success'); - const requestHeaders: { [key: string]: string } = request.requestHeaders as { [key: string]: string }; - expect(requestHeaders['Accept']).toBe('application/json'); - }); - - describe('Rate-limiting', () => { - it('back-off using Retry-After header', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - server.respondWith('POST', storeUrl, [429, { 'Retry-After': `${retryAfterSeconds}` }, '']); - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - false - .mockImplementationOnce(() => afterLimit) - // 3rd event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(server.requests.length).toBe(1); - } - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(1); - } + }), + }; - server.respondWith('POST', storeUrl, [200, {}, '']); + // casting `window` as `any` because XMLHttpRequest is missing in Window (TS-only) + jest.spyOn(window as any, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest); - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(server.requests.length).toBe(2); - }); + return xhrMock; +} - it('back-off using X-Sentry-Rate-Limits with single category', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; +describe('NewXHRTransport', () => { + const xhrMock: Partial = createXHRMock(); - server.respondWith('POST', storeUrl, [429, { 'X-Sentry-Rate-Limits': `${retryAfterSeconds}:error:scope` }, '']); - server.respondWith('POST', envelopeUrl, [200, {}, '']); - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - false (different category) - .mockImplementationOnce(() => withinLimit) - // 2nd event - _handleRateLimit - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - true - .mockImplementationOnce(() => withinLimit) - // 4th event - _isRateLimited - false - .mockImplementationOnce(() => afterLimit) - // 4th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(server.requests.length).toBe(1); - } - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toBe('success'); - expect(server.requests.length).toBe(2); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(2); - } - - server.respondWith('POST', storeUrl, [200, {}, '']); - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(server.requests.length).toBe(3); - }); - - it('back-off using X-Sentry-Rate-Limits with multiple categories', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - server.respondWith('POST', storeUrl, [ - 429, - { 'X-Sentry-Rate-Limits': `${retryAfterSeconds}:error;transaction:scope` }, - '', - ]); - server.respondWith('POST', envelopeUrl, [200, {}, '']); - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true (event category) - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - true (transaction category) - .mockImplementationOnce(() => withinLimit) - // 4th event - _isRateLimited - false (event category) - .mockImplementationOnce(() => afterLimit) - // 4th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit) - // 5th event - _isRateLimited - false (transaction category) - .mockImplementationOnce(() => afterLimit) - // 5th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(server.requests.length).toBe(1); - } - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(1); - } - - try { - await transport.sendEvent(transactionPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for transaction requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(1); - } - - server.respondWith('POST', storeUrl, [200, {}, '']); - server.respondWith('POST', envelopeUrl, [200, {}, '']); - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(server.requests.length).toBe(2); - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toBe('success'); - expect(server.requests.length).toBe(3); - }); - - it('back-off using X-Sentry-Rate-Limits with missing categories should lock them all', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; - - server.respondWith('POST', storeUrl, [429, { 'X-Sentry-Rate-Limits': `${retryAfterSeconds}::scope` }, '']); - server.respondWith('POST', envelopeUrl, [200, {}, '']); - - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _isRateLimited - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true (event category) - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - true (transaction category) - .mockImplementationOnce(() => withinLimit) - // 4th event - _isRateLimited - false (event category) - .mockImplementationOnce(() => afterLimit) - // 4th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit) - // 5th event - _isRateLimited - false (transaction category) - .mockImplementationOnce(() => afterLimit) - // 5th event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBeUndefined(); - expect(server.requests.length).toBe(1); - } - - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(1); - } - - try { - await transport.sendEvent(transactionPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for transaction requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(1); - } - - server.respondWith('POST', storeUrl, [200, {}, '']); - server.respondWith('POST', envelopeUrl, [200, {}, '']); - - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(server.requests.length).toBe(2); - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toBe('success'); - expect(server.requests.length).toBe(3); - }); + afterEach(() => { + jest.clearAllMocks(); + }); - it('back-off using X-Sentry-Rate-Limits should also trigger for 200 responses', async () => { - const retryAfterSeconds = 10; - const beforeLimit = Date.now(); - const withinLimit = beforeLimit + (retryAfterSeconds / 2) * 1000; - const afterLimit = beforeLimit + retryAfterSeconds * 1000; + afterAll(() => { + jest.restoreAllMocks(); + }); - server.respondWith('POST', storeUrl, [200, { 'X-Sentry-Rate-Limits': `${retryAfterSeconds}:error:scope` }, '']); + it('makes an XHR request to the given URL', async () => { + const transport = makeXHRTransport(DEFAULT_XHR_TRANSPORT_OPTIONS); + expect(xhrMock.open).toHaveBeenCalledTimes(0); + expect(xhrMock.setRequestHeader).toHaveBeenCalledTimes(0); + expect(xhrMock.send).toHaveBeenCalledTimes(0); - jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 1st event - _handleRateLimit - .mockImplementationOnce(() => beforeLimit) - // 2nd event - _isRateLimited - true - .mockImplementationOnce(() => withinLimit) - // 3rd event - _isRateLimited - false - .mockImplementationOnce(() => afterLimit) - // 3rd event - _handleRateLimit - .mockImplementationOnce(() => afterLimit); + await Promise.all([transport.send(ERROR_ENVELOPE), (xhrMock as XMLHttpRequest).onreadystatechange!({} as Event)]); - let eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(server.requests.length).toBe(1); + 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)); + }); - try { - await transport.sendEvent(eventPayload); - throw new Error('unreachable!'); - } catch (res) { - expect((res as Response).status).toBe(429); - expect((res as Response).reason).toBe( - `Transport for event requests locked till ${new Date(afterLimit)} due to too many requests.`, - ); - expect(server.requests.length).toBe(1); - } + it('returns the correct response', async () => { + const transport = makeXHRTransport(DEFAULT_XHR_TRANSPORT_OPTIONS); - server.respondWith('POST', storeUrl, [200, {}, '']); + const [res] = await Promise.all([ + transport.send(ERROR_ENVELOPE), + (xhrMock as XMLHttpRequest).onreadystatechange!({} as Event), + ]); - eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toBe('success'); - expect(server.requests.length).toBe(2); - }); + expect(res).toBeDefined(); + expect(res.status).toEqual('success'); + }); - it('should record dropped event', async () => { - // @ts-ignore private method - jest.spyOn(transport, '_isRateLimited').mockReturnValue(true); + it('sets rate limit response headers', async () => { + const transport = makeXHRTransport(DEFAULT_XHR_TRANSPORT_OPTIONS); - const spy = jest.spyOn(transport, 'recordLostEvent'); + await Promise.all([transport.send(ERROR_ENVELOPE), (xhrMock as XMLHttpRequest).onreadystatechange!({} as Event)]); - try { - await transport.sendEvent(eventPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('ratelimit_backoff', 'event'); - } + expect(xhrMock.getResponseHeader).toHaveBeenCalledTimes(2); + expect(xhrMock.getResponseHeader).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(xhrMock.getResponseHeader).toHaveBeenCalledWith('Retry-After'); + }); - try { - await transport.sendEvent(transactionPayload); - } catch (_) { - expect(spy).toHaveBeenCalledWith('ratelimit_backoff', 'transaction'); - } - }); - }); + 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 = makeXHRTransport(options); + await Promise.all([transport.send(ERROR_ENVELOPE), (xhrMock as XMLHttpRequest).onreadystatechange!({} as Event)]); + + 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); }); }); diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index e2502289742a..833b40aaf9c7 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -9,7 +9,6 @@ import { EventHint, Integration, IntegrationClass, - NewTransport, SessionAggregates, Severity, SeverityLevel, @@ -77,7 +76,7 @@ export abstract class BaseClient implements Client { /** The client Dsn, if specified in options. Without this Dsn, the SDK will be disabled. */ protected readonly _dsn?: DsnComponents; - protected readonly _transport?: NewTransport; + protected readonly _transport?: Transport; /** Array of set up integrations. */ protected _integrations: IntegrationIndex = {}; @@ -92,8 +91,6 @@ export abstract class BaseClient implements Client { * Initializes this client instance. * * @param options Options for the client. - * @param transport The (old) Transport instance for the client to use (TODO(v7): remove) - * @param newTransport The NewTransport instance for the client to use */ protected constructor(options: O) { this._options = options; @@ -213,7 +210,7 @@ export abstract class BaseClient implements Client { /** * @inheritDoc */ - public getTransport(): NewTransport | undefined { + public getTransport(): Transport | undefined { return this._transport; } @@ -567,12 +564,12 @@ export abstract class BaseClient implements Client { // eslint-disable-next-line @typescript-eslint/unbound-method const { beforeSend, sampleRate } = this.getOptions(); - type RecordLostEvent = NonNullable; - type RecordLostEventParams = Parameters; + // type RecordLostEvent = NonNullable; + type RecordLostEventParams = unknown[]; // Parameters; + // no-op as new transports don't have client outcomes + // TODO(v7): Re-add this functionality function recordLostEvent(_outcome: RecordLostEventParams[0], _category: RecordLostEventParams[1]): void { - // no-op as new transports don't have client outcomes - // TODO(v7): Re-add this functionality // if (transport.recordLostEvent) { // transport.recordLostEvent(outcome, category); // } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5932bdbeaa4..0b0585a3da68 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,6 @@ export { export { BaseClient } from './baseclient'; export { eventToSentryRequest, sessionToSentryRequest } from './request'; export { initAndBind } from './sdk'; -export { NoopTransport } from './transports/noop'; export { createTransport } from './transports/base'; export { SDK_VERSION } from './version'; export { getIntegrationsToSetup } from './integration'; diff --git a/packages/core/src/transports/base.ts b/packages/core/src/transports/base.ts index 8c6cfe373bfe..97389ea9a45a 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -1,7 +1,7 @@ import { Envelope, InternalBaseTransportOptions, - NewTransport, + Transport, TransportCategory, TransportRequest, TransportRequestExecutor, @@ -24,7 +24,7 @@ import { export const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; /** - * Creates a `NewTransport` + * Creates an instance of a Sentry `Transport` * * @param options * @param makeRequest @@ -33,7 +33,7 @@ export function createTransport( options: InternalBaseTransportOptions, makeRequest: TransportRequestExecutor, buffer: PromiseBuffer = makePromiseBuffer(options.bufferSize || DEFAULT_TRANSPORT_BUFFER_SIZE), -): NewTransport { +): Transport { let rateLimits: RateLimits = {}; const flush = (timeout?: number): PromiseLike => buffer.drain(timeout); diff --git a/packages/core/src/transports/noop.ts b/packages/core/src/transports/noop.ts deleted file mode 100644 index 8ea66c7b8a4e..000000000000 --- a/packages/core/src/transports/noop.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Event, Response, Transport } from '@sentry/types'; -import { resolvedSyncPromise } from '@sentry/utils'; - -/** Noop transport */ -export class NoopTransport implements Transport { - /** - * @inheritDoc - */ - public sendEvent(_: Event): PromiseLike { - return resolvedSyncPromise({ - reason: 'NoopTransport: Event has been skipped because no Dsn is configured.', - status: 'skipped', - }); - } - - /** - * @inheritDoc - */ - public close(_?: number): PromiseLike { - return resolvedSyncPromise(true); - } -} diff --git a/packages/core/test/lib/transports/base.test.ts b/packages/core/test/lib/transports/base.test.ts index 78b40d0a4f7c..8b4dc8965c02 100644 --- a/packages/core/test/lib/transports/base.test.ts +++ b/packages/core/test/lib/transports/base.test.ts @@ -1,4 +1,4 @@ -import { EventEnvelope, EventItem, NewTransport, TransportMakeRequestResponse, TransportResponse } from '@sentry/types'; +import { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse, TransportResponse } from '@sentry/types'; import { createEnvelope, PromiseBuffer, resolvedSyncPromise, serializeEnvelope } from '@sentry/utils'; import { createTransport } from '../../../src/transports/base'; @@ -88,7 +88,7 @@ describe('createTransport', () => { function createTestTransport( initialTransportResponse: TransportMakeRequestResponse, - ): [NewTransport, (res: TransportMakeRequestResponse) => void] { + ): [Transport, (res: TransportMakeRequestResponse) => void] { let transportResponse: TransportMakeRequestResponse = initialTransportResponse; function setTransportResponse(res: TransportMakeRequestResponse) { diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts index 9f02e5b6540d..076b1f7b34c2 100644 --- a/packages/core/test/mocks/client.ts +++ b/packages/core/test/mocks/client.ts @@ -6,6 +6,10 @@ import { BaseClient } from '../../src/baseclient'; import { initAndBind } from '../../src/sdk'; import { createTransport } from '../../src/transports/base'; +// TODO(v7): Add client reports tests to this file +// See old tests in packages/browser/test/unit/transports/base.test.ts +// from https://github.com/getsentry/sentry-javascript/pull/4967 + export function getDefaultTestClientOptions(options: Partial = {}): TestClientOptions { return { integrations: [], diff --git a/packages/node-integration-tests/suites/public-api/startTransaction/new-transport/scenario.ts b/packages/node-integration-tests/suites/public-api/startTransaction/new-transport/scenario.ts deleted file mode 100644 index 82ae5e905410..000000000000 --- a/packages/node-integration-tests/suites/public-api/startTransaction/new-transport/scenario.ts +++ /dev/null @@ -1,17 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import '@sentry/tracing'; - -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - _experiments: { - newTransport: true, // use new transport - }, -}); - -const transaction = Sentry.startTransaction({ name: 'test_transaction_1' }); - -transaction.finish(); diff --git a/packages/node-integration-tests/suites/public-api/startTransaction/new-transport/test.ts b/packages/node-integration-tests/suites/public-api/startTransaction/new-transport/test.ts deleted file mode 100644 index 62b8d8fb4402..000000000000 --- a/packages/node-integration-tests/suites/public-api/startTransaction/new-transport/test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { assertSentryTransaction, getEnvelopeRequest, runServer } from '../../../../utils'; - -test('should send a manually started transaction when @sentry/tracing is imported using unnamed import', async () => { - const url = await runServer(__dirname); - const envelope = await getEnvelopeRequest(url); - - expect(envelope).toHaveLength(3); - - assertSentryTransaction(envelope[2], { - transaction: 'test_transaction_1', - }); -}); diff --git a/packages/node/src/transports/base/index.ts b/packages/node/src/transports/base/index.ts deleted file mode 100644 index 0cbe39c42b2b..000000000000 --- a/packages/node/src/transports/base/index.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { APIDetails, getRequestHeaders, initAPIDetails, SDK_VERSION } from '@sentry/core'; -import { - DsnProtocol, - Event, - Response, - SentryRequest, - SentryRequestType, - Session, - SessionAggregates, - Transport, - TransportOptions, -} from '@sentry/types'; -import { - eventStatusFromHttpCode, - logger, - makePromiseBuffer, - parseRetryAfterHeader, - PromiseBuffer, - SentryError, -} from '@sentry/utils'; -import * as fs from 'fs'; -import * as http from 'http'; -import * as https from 'https'; -import { URL } from 'url'; - -import { IS_DEBUG_BUILD } from '../../flags'; -import { SDK_NAME } from '../../version'; -import { HTTPModule } from './http-module'; - -export type URLParts = Pick; -export type UrlParser = (url: string) => URLParts; - -const CATEGORY_MAPPING: { - [key in SentryRequestType]: string; -} = { - event: 'error', - transaction: 'transaction', - session: 'session', - attachment: 'attachment', -}; - -/** Base Transport class implementation */ -export abstract class BaseTransport implements Transport { - /** The Agent used for corresponding transport */ - public module?: HTTPModule; - - /** The Agent used for corresponding transport */ - public client?: http.Agent | https.Agent; - - /** API object */ - protected _api: APIDetails; - - /** A simple buffer holding all requests. */ - protected readonly _buffer: PromiseBuffer = makePromiseBuffer(30); - - /** Locks transport after receiving rate limits in a response */ - protected readonly _rateLimits: Record = {}; - - /** Create instance and set this.dsn */ - public constructor(public options: TransportOptions) { - // eslint-disable-next-line deprecation/deprecation - this._api = initAPIDetails(options.dsn, options._metadata, options.tunnel); - } - - /** Default function used to parse URLs */ - public urlParser: UrlParser = url => new URL(url); - - /** - * @inheritDoc - */ - public sendEvent(_: Event): PromiseLike { - throw new SentryError('Transport Class has to implement `sendEvent` method.'); - } - - /** - * @inheritDoc - */ - public close(timeout?: number): PromiseLike { - return this._buffer.drain(timeout); - } - - /** - * Extracts proxy settings from client options and env variables. - * - * Honors `no_proxy` env variable with the highest priority to allow for hosts exclusion. - * - * An order of priority for available protocols is: - * `http` => `options.httpProxy` | `process.env.http_proxy` - * `https` => `options.httpsProxy` | `options.httpProxy` | `process.env.https_proxy` | `process.env.http_proxy` - */ - protected _getProxy(protocol: DsnProtocol): string | undefined { - const { no_proxy, http_proxy, https_proxy } = process.env; - const { httpProxy, httpsProxy } = this.options; - const proxy = protocol === 'http' ? httpProxy || http_proxy : httpsProxy || httpProxy || https_proxy || http_proxy; - - if (!no_proxy) { - return proxy; - } - - const { host, port } = this._api.dsn; - for (const np of no_proxy.split(',')) { - if (host.endsWith(np) || `${host}:${port}`.endsWith(np)) { - return; - } - } - - return proxy; - } - - /** Returns a build request option object used by request */ - protected _getRequestOptions(urlParts: URLParts): http.RequestOptions | https.RequestOptions { - const headers = { - ...getRequestHeaders(this._api.dsn, SDK_NAME, SDK_VERSION), - ...this.options.headers, - }; - const { hostname, pathname, port, protocol } = urlParts; - // See https://github.com/nodejs/node/blob/38146e717fed2fabe3aacb6540d839475e0ce1c6/lib/internal/url.js#L1268-L1290 - // We ignore the query string on purpose - const path = `${pathname}`; - - return { - agent: this.client, - headers, - hostname, - method: 'POST', - path, - port, - protocol, - ...(this.options.caCerts && { - ca: fs.readFileSync(this.options.caCerts), - }), - }; - } - - /** - * Gets the time that given category is disabled until for rate limiting - */ - protected _disabledUntil(requestType: SentryRequestType): Date { - const category = CATEGORY_MAPPING[requestType]; - return this._rateLimits[category] || this._rateLimits.all; - } - - /** - * Checks if a category is rate limited - */ - protected _isRateLimited(requestType: SentryRequestType): boolean { - return this._disabledUntil(requestType) > new Date(Date.now()); - } - - /** - * Sets internal _rateLimits from incoming headers. Returns true if headers contains a non-empty rate limiting header. - */ - protected _handleRateLimit(headers: Record): boolean { - const now = Date.now(); - const rlHeader = headers['x-sentry-rate-limits']; - const raHeader = headers['retry-after']; - - if (rlHeader) { - // rate limit headers are of the form - //
,
,.. - // where each
is of the form - // : : : - // where - // is a delay in ms - // is the event type(s) (error, transaction, etc) being rate limited and is of the form - // ;;... - // is what's being limited (org, project, or key) - ignored by SDK - // is an arbitrary string like "org_quota" - ignored by SDK - for (const limit of rlHeader.trim().split(',')) { - const parameters = limit.split(':', 2); - const headerDelay = parseInt(parameters[0], 10); - const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default - for (const category of (parameters[1] && parameters[1].split(';')) || ['all']) { - // categoriesAllowed is added here to ensure we are only storing rate limits for categories we support in this - // sdk and any categories that are not supported will not be added redundantly to the rateLimits object - const categoriesAllowed = [ - ...(Object.keys(CATEGORY_MAPPING) as [SentryRequestType]).map(k => CATEGORY_MAPPING[k]), - 'all', - ]; - if (categoriesAllowed.includes(category)) this._rateLimits[category] = new Date(now + delay); - } - } - return true; - } else if (raHeader) { - this._rateLimits.all = new Date(now + parseRetryAfterHeader(raHeader, now)); - return true; - } - return false; - } - - /** JSDoc */ - protected async _send( - sentryRequest: SentryRequest, - originalPayload?: Event | Session | SessionAggregates, - ): Promise { - if (!this.module) { - throw new SentryError('No module available'); - } - if (originalPayload && this._isRateLimited(sentryRequest.type)) { - return Promise.reject({ - payload: originalPayload, - type: sentryRequest.type, - reason: `Transport for ${sentryRequest.type} requests locked till ${this._disabledUntil( - sentryRequest.type, - )} due to too many requests.`, - status: 429, - }); - } - - return this._buffer.add( - () => - new Promise((resolve, reject) => { - if (!this.module) { - throw new SentryError('No module available'); - } - const options = this._getRequestOptions(this.urlParser(sentryRequest.url)); - const req = this.module.request(options, res => { - const statusCode = res.statusCode || 500; - const status = eventStatusFromHttpCode(statusCode); - - res.setEncoding('utf8'); - - /** - * "Key-value pairs of header names and values. Header names are lower-cased." - * https://nodejs.org/api/http.html#http_message_headers - */ - let retryAfterHeader = res.headers ? res.headers['retry-after'] : ''; - retryAfterHeader = (Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader) as string; - - let rlHeader = res.headers ? res.headers['x-sentry-rate-limits'] : ''; - rlHeader = (Array.isArray(rlHeader) ? rlHeader[0] : rlHeader) as string; - - const headers = { - 'x-sentry-rate-limits': rlHeader, - 'retry-after': retryAfterHeader, - }; - - const limited = this._handleRateLimit(headers); - if (limited) - IS_DEBUG_BUILD && - logger.warn( - `Too many ${sentryRequest.type} requests, backing off until: ${this._disabledUntil( - sentryRequest.type, - )}`, - ); - - if (status === 'success') { - resolve({ status }); - } else { - let rejectionMessage = `HTTP Error (${statusCode})`; - if (res.headers && res.headers['x-sentry-error']) { - rejectionMessage += `: ${res.headers['x-sentry-error']}`; - } - reject(new SentryError(rejectionMessage)); - } - - // Force the socket to drain - res.on('data', () => { - // Drain - }); - res.on('end', () => { - // Drain - }); - }); - req.on('error', reject); - req.end(sentryRequest.body); - }), - ); - } -} diff --git a/packages/node/src/transports/base/http-module.ts b/packages/node/src/transports/http-module.ts similarity index 100% rename from packages/node/src/transports/base/http-module.ts rename to packages/node/src/transports/http-module.ts diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index a9234d830876..0352a8232e04 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -1,32 +1,134 @@ -import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core'; -import { Event, Response, Session, SessionAggregates, TransportOptions } from '@sentry/types'; +import { createTransport } from '@sentry/core'; +import { + BaseTransportOptions, + Transport, + TransportMakeRequestResponse, + TransportRequest, + TransportRequestExecutor, +} from '@sentry/types'; +import { eventStatusFromHttpCode } from '@sentry/utils'; import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; -import { BaseTransport } from './base'; - -/** Node http module transport */ -export class HTTPTransport extends BaseTransport { - /** Create a new instance and set this.agent */ - public constructor(public options: TransportOptions) { - super(options); - const proxy = this._getProxy('http'); - this.module = http; - this.client = proxy - ? (new (require('https-proxy-agent'))(proxy) as http.Agent) - : new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); - } +import { HTTPModule } from './http-module'; - /** - * @inheritDoc - */ - public sendEvent(event: Event): Promise { - return this._send(eventToSentryRequest(event, this._api), event); - } +export interface NodeTransportOptions extends BaseTransportOptions { + /** Define custom headers */ + headers?: Record; + /** Set a proxy that should be used for outbound requests. */ + proxy?: string; + /** HTTPS proxy CA certificates */ + caCerts?: string | Buffer | Array; + /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ + httpModule?: HTTPModule; +} + +/** + * Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry. + */ +export function makeNodeTransport(options: NodeTransportOptions): Transport { + const urlSegments = new URL(options.url); + const isHttps = urlSegments.protocol === 'https:'; + + // Proxy prioritization: http => `options.proxy` | `process.env.http_proxy` + // Proxy prioritization: https => `options.proxy` | `process.env.https_proxy` | `process.env.http_proxy` + const proxy = applyNoProxyOption( + urlSegments, + options.proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy, + ); + + const nativeHttpModule = isHttps ? https : http; + + // TODO(v7): Evaluate if we can set keepAlive to true. This would involve testing for memory leaks in older node + // versions(>= 8) as they had memory leaks when using it: #2555 + const agent = proxy + ? (new (require('https-proxy-agent'))(proxy) as http.Agent) + : new nativeHttpModule.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); + + const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); + return createTransport({ bufferSize: options.bufferSize }, requestExecutor); +} + +/** + * Honors the `no_proxy` env variable with the highest priority to allow for hosts exclusion. + * + * @param transportUrl The URL the transport intends to send events to. + * @param proxy The client configured proxy. + * @returns A proxy the transport should use. + */ +function applyNoProxyOption(transportUrlSegments: URL, proxy: string | undefined): string | undefined { + const { no_proxy } = process.env; - /** - * @inheritDoc - */ - public sendSession(session: Session | SessionAggregates): PromiseLike { - return this._send(sessionToSentryRequest(session, this._api), session); + const urlIsExemptFromProxy = + no_proxy && + no_proxy + .split(',') + .some( + exemption => transportUrlSegments.host.endsWith(exemption) || transportUrlSegments.hostname.endsWith(exemption), + ); + + if (urlIsExemptFromProxy) { + return undefined; + } else { + return proxy; } } + +/** + * Creates a RequestExecutor to be used with `createTransport`. + */ +function createRequestExecutor( + options: NodeTransportOptions, + httpModule: HTTPModule, + agent: http.Agent, +): TransportRequestExecutor { + const { hostname, pathname, port, protocol, search } = new URL(options.url); + return function makeRequest(request: TransportRequest): Promise { + return new Promise((resolve, reject) => { + const req = httpModule.request( + { + method: 'POST', + agent, + headers: options.headers, + hostname, + path: `${pathname}${search}`, + port, + protocol, + ca: options.caCerts, + }, + res => { + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + + const statusCode = res.statusCode ?? 500; + const status = eventStatusFromHttpCode(statusCode); + + res.setEncoding('utf8'); + + // "Key-value pairs of header names and values. Header names are lower-cased." + // https://nodejs.org/api/http.html#http_message_headers + const retryAfterHeader = res.headers['retry-after'] ?? null; + const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; + + resolve({ + headers: { + 'retry-after': retryAfterHeader, + 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) ? rateLimitsHeader[0] : rateLimitsHeader, + }, + reason: status, + statusCode: statusCode, + }); + }, + ); + + req.on('error', reject); + req.end(request.body); + }); + }; +} diff --git a/packages/node/src/transports/https.ts b/packages/node/src/transports/https.ts deleted file mode 100644 index d6c312608504..000000000000 --- a/packages/node/src/transports/https.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core'; -import { Event, Response, Session, SessionAggregates, TransportOptions } from '@sentry/types'; -import * as https from 'https'; - -import { BaseTransport } from './base'; - -/** Node https module transport */ -export class HTTPSTransport extends BaseTransport { - /** Create a new instance and set this.agent */ - public constructor(public options: TransportOptions) { - super(options); - const proxy = this._getProxy('https'); - this.module = https; - this.client = proxy - ? (new (require('https-proxy-agent'))(proxy) as https.Agent) - : new https.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); - } - - /** - * @inheritDoc - */ - public sendEvent(event: Event): Promise { - return this._send(eventToSentryRequest(event, this._api), event); - } - - /** - * @inheritDoc - */ - public sendSession(session: Session | SessionAggregates): PromiseLike { - return this._send(sessionToSentryRequest(session, this._api), session); - } -} diff --git a/packages/node/src/transports/index.ts b/packages/node/src/transports/index.ts index 958562933321..ba59ba8878a4 100644 --- a/packages/node/src/transports/index.ts +++ b/packages/node/src/transports/index.ts @@ -1,6 +1,3 @@ -export type { NodeTransportOptions } from './new'; +export type { NodeTransportOptions } from './http'; -export { BaseTransport } from './base'; -export { HTTPTransport } from './http'; -export { HTTPSTransport } from './https'; -export { makeNodeTransport } from './new'; +export { makeNodeTransport } from './http'; diff --git a/packages/node/src/transports/new.ts b/packages/node/src/transports/new.ts deleted file mode 100644 index e31a58b03b7b..000000000000 --- a/packages/node/src/transports/new.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createTransport } from '@sentry/core'; -import { - BaseTransportOptions, - NewTransport, - TransportMakeRequestResponse, - TransportRequest, - TransportRequestExecutor, -} from '@sentry/types'; -import { eventStatusFromHttpCode } from '@sentry/utils'; -import * as http from 'http'; -import * as https from 'https'; -import { URL } from 'url'; - -import { HTTPModule } from './base/http-module'; - -// TODO(v7): -// - Rename this file "transport.ts" -// - Move this file one folder upwards -// - Delete "transports" folder -// OR -// - Split this file up and leave it in the transports folder - -export interface NodeTransportOptions extends BaseTransportOptions { - /** Define custom headers */ - headers?: Record; - /** Set a proxy that should be used for outbound requests. */ - proxy?: string; - /** HTTPS proxy CA certificates */ - caCerts?: string | Buffer | Array; - /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ - httpModule?: HTTPModule; -} - -/** - * Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry. - */ -export function makeNodeTransport(options: NodeTransportOptions): NewTransport { - const urlSegments = new URL(options.url); - const isHttps = urlSegments.protocol === 'https:'; - - // Proxy prioritization: http => `options.proxy` | `process.env.http_proxy` - // Proxy prioritization: https => `options.proxy` | `process.env.https_proxy` | `process.env.http_proxy` - const proxy = applyNoProxyOption( - urlSegments, - options.proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy, - ); - - const nativeHttpModule = isHttps ? https : http; - - // TODO(v7): Evaluate if we can set keepAlive to true. This would involve testing for memory leaks in older node - // versions(>= 8) as they had memory leaks when using it: #2555 - const agent = proxy - ? (new (require('https-proxy-agent'))(proxy) as http.Agent) - : new nativeHttpModule.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); - - const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); - return createTransport({ bufferSize: options.bufferSize }, requestExecutor); -} - -/** - * Honors the `no_proxy` env variable with the highest priority to allow for hosts exclusion. - * - * @param transportUrl The URL the transport intends to send events to. - * @param proxy The client configured proxy. - * @returns A proxy the transport should use. - */ -function applyNoProxyOption(transportUrlSegments: URL, proxy: string | undefined): string | undefined { - const { no_proxy } = process.env; - - const urlIsExemptFromProxy = - no_proxy && - no_proxy - .split(',') - .some( - exemption => transportUrlSegments.host.endsWith(exemption) || transportUrlSegments.hostname.endsWith(exemption), - ); - - if (urlIsExemptFromProxy) { - return undefined; - } else { - return proxy; - } -} - -/** - * Creates a RequestExecutor to be used with `createTransport`. - */ -function createRequestExecutor( - options: NodeTransportOptions, - httpModule: HTTPModule, - agent: http.Agent, -): TransportRequestExecutor { - const { hostname, pathname, port, protocol, search } = new URL(options.url); - return function makeRequest(request: TransportRequest): Promise { - return new Promise((resolve, reject) => { - const req = httpModule.request( - { - method: 'POST', - agent, - headers: options.headers, - hostname, - path: `${pathname}${search}`, - port, - protocol, - ca: options.caCerts, - }, - res => { - res.on('data', () => { - // Drain socket - }); - - res.on('end', () => { - // Drain socket - }); - - const statusCode = res.statusCode ?? 500; - const status = eventStatusFromHttpCode(statusCode); - - res.setEncoding('utf8'); - - // "Key-value pairs of header names and values. Header names are lower-cased." - // https://nodejs.org/api/http.html#http_message_headers - const retryAfterHeader = res.headers['retry-after'] ?? null; - const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; - - resolve({ - headers: { - 'retry-after': retryAfterHeader, - 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) ? rateLimitsHeader[0] : rateLimitsHeader, - }, - reason: status, - statusCode: statusCode, - }); - }, - ); - - req.on('error', reject); - req.end(request.body); - }); - }; -} diff --git a/packages/node/test/transports/custom/index.test.ts b/packages/node/test/transports/custom/index.test.ts deleted file mode 100644 index 88295bbc7e9b..000000000000 --- a/packages/node/test/transports/custom/index.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CustomUrlTransport } from './transports'; - -describe('Custom transport', () => { - describe('URL parser support', () => { - const noop = () => null; - const sampleDsn = 'https://username@sentry.tld/path/1'; - - test('use URL parser for sendEvent() method', async () => { - const urlParser = jest.fn(); - const transport = new CustomUrlTransport({ dsn: sampleDsn }, urlParser); - await transport.sendEvent({}).catch(noop); - - expect(urlParser).toHaveBeenCalled(); - }); - - test('use URL parser for sendSession() method', async () => { - const urlParser = jest.fn(); - const transport = new CustomUrlTransport({ dsn: sampleDsn }, urlParser); - await transport.sendSession({ aggregates: [] }).then(noop, noop); - - expect(urlParser).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/node/test/transports/custom/transports.ts b/packages/node/test/transports/custom/transports.ts deleted file mode 100644 index e3305fbf8a20..000000000000 --- a/packages/node/test/transports/custom/transports.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TransportOptions } from '@sentry/types'; - -import { HTTPTransport } from '../../../src/transports'; -import { UrlParser } from '../../../src/transports/base'; - -export class CustomUrlTransport extends HTTPTransport { - public constructor(public options: TransportOptions, urlParser: UrlParser) { - super(options); - this.urlParser = urlParser; - } -} diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index 8c7abfa2ade2..d8e1f5889226 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -1,711 +1,346 @@ -import { Session } from '@sentry/hub'; -import { Event, SessionAggregates, TransportOptions } from '@sentry/types'; -import { SentryError } from '@sentry/utils'; +import { createTransport } from '@sentry/core'; +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; import * as http from 'http'; -import * as HttpsProxyAgent from 'https-proxy-agent'; - -import { HTTPTransport } from '../../src/transports/http'; - -const mockSetEncoding = jest.fn(); -const dsn = 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622'; -const storePath = '/mysubpath/api/50622/store/'; -const envelopePath = '/mysubpath/api/50622/envelope/'; -const tunnel = 'https://hello.com/world'; -const eventPayload: Event = { - event_id: '1337', -}; -const transactionPayload: Event = { - event_id: '42', - type: 'transaction', -}; -const sessionPayload: Session = { - environment: 'test', - release: '1.0', - sid: '353463243253453254', - errors: 0, - started: Date.now(), - timestamp: Date.now(), - init: true, - duration: 0, - status: 'exited', - update: jest.fn(), - close: jest.fn(), - toJSON: jest.fn(), - ignoreDuration: false, -}; -const sessionsPayload: SessionAggregates = { - attrs: { environment: 'test', release: '1.0' }, - aggregates: [{ started: '2021-03-17T16:00:00.000Z', exited: 1 }], -}; -let mockReturnCode = 200; -let mockHeaders = {}; - -function createTransport(options: TransportOptions): HTTPTransport { - const transport = new HTTPTransport(options); - transport.module = { - request: jest.fn().mockImplementation((_options: any, callback: any) => ({ - end: () => { - callback({ - headers: mockHeaders, - setEncoding: mockSetEncoding, - statusCode: mockReturnCode, - }); - }, - on: jest.fn(), - })), + +import { makeNodeTransport } from '../../src/transports'; + +jest.mock('@sentry/core', () => { + const actualCore = jest.requireActual('@sentry/core'); + return { + ...actualCore, + createTransport: jest.fn().mockImplementation(actualCore.createTransport), }; - return transport; -} +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const httpProxyAgent = require('https-proxy-agent'); +jest.mock('https-proxy-agent', () => { + return jest.fn().mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); +}); + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; -function assertBasicOptions(options: any, useEnvelope: boolean = false): void { - expect(options.headers['X-Sentry-Auth']).toContain('sentry_version'); - expect(options.headers['X-Sentry-Auth']).toContain('sentry_client'); - expect(options.headers['X-Sentry-Auth']).toContain('sentry_key'); - expect(options.port).toEqual('8989'); - expect(options.path).toEqual(useEnvelope ? envelopePath : storePath); - expect(options.hostname).toEqual('sentry.io'); +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; } -describe('HTTPTransport', () => { - beforeEach(() => { - mockReturnCode = 200; - mockHeaders = {}; - jest.clearAllMocks(); - }); +let testServer: http.Server | undefined; - test('send 200', async () => { - const transport = createTransport({ dsn }); - await transport.sendEvent({ - message: 'test', +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string) => void, +) { + testServer = http.createServer((req, res) => { + let body = ''; + + req.on('data', data => { + body += data; }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(mockSetEncoding).toHaveBeenCalled(); - }); + req.on('end', () => { + requestInspector?.(req, body); + }); - test('send 400', async () => { - mockReturnCode = 400; - const transport = createTransport({ dsn }); + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); - try { - await transport.sendEvent({ - message: 'test', - }); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + // also terminate socket because keepalive hangs connection a bit + res.connection.end(); }); - test('send 200 session', async () => { - const transport = createTransport({ dsn }); - await transport.sendSession(new Session()); + testServer.listen(18099); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(mockSetEncoding).toHaveBeenCalled(); + return new Promise(resolve => { + testServer?.on('listening', resolve); }); +} - test('send 400 session', async () => { - mockReturnCode = 400; - const transport = createTransport({ dsn }); +const TEST_SERVER_URL = 'http://localhost:18099'; - try { - await transport.sendSession(new Session()); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } - }); +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); - test('send 200 request mode sessions', async () => { - const transport = createTransport({ dsn }); - await transport.sendSession(sessionsPayload); +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(mockSetEncoding).toHaveBeenCalled(); - }); - - test('send 400 request mode session', async () => { - mockReturnCode = 400; - const transport = createTransport({ dsn }); +describe('makeNewHttpTransport()', () => { + afterEach(() => { + jest.clearAllMocks(); - try { - await transport.sendSession(sessionsPayload); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); + if (testServer) { + testServer.close(); } }); - test('send x-sentry-error header', async () => { - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-error': 'test-failed', - }; - const transport = createTransport({ dsn }); - - try { - await transport.sendEvent({ - message: 'test', - }); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode}): test-failed`)); - } - }); + describe('.send()', () => { + it('should correctly return successful server response', async () => { + await setupTestServer({ statusCode: SUCCESS }); - test('sends a request to tunnel if configured', async () => { - const transport = createTransport({ dsn, tunnel }); + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); - await transport.sendEvent({ - message: 'test', + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - expect(requestOptions.protocol).toEqual('https:'); - expect(requestOptions.hostname).toEqual('hello.com'); - expect(requestOptions.path).toEqual('/world'); - }); - - test('back-off using retry-after header', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 429; - mockHeaders = { - 'retry-after': retryAfterSeconds, - }; - const transport = createTransport({ dsn }); - - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // Check for first event - .mockReturnValueOnce(now) - // Setting disabledUntil - .mockReturnValueOnce(now) - // Check for second event - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // Check for third event - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent({ message: 'test' }); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } - - try { - await transport.sendEvent({ message: 'test' }); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload.message).toEqual('test'); - expect(e.type).toEqual('event'); - } - - try { - await transport.sendEvent({ message: 'test' }); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); - mock.mockRestore(); - }); + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + await transport.send(EVENT_ENVELOPE); + }); - test('back-off using x-sentry-rate-limits with bogus headers and missing categories should just lock them all', async () => { - const retryAfterSeconds = 60; - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-rate-limits': 'sgthrthewhertht', - }; - const transport = createTransport({ dsn }); - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockReturnValueOnce(now) - // 1st event - _handleRateLimit - .mockReturnValueOnce(now) - // 2nd event - _isRateLimited - true (event category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _isRateLimited - true (transaction category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 4th event - _isRateLimited - false (event category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 4th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 5th event - _isRateLimited - false (transaction category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 5th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(eventPayload); - expect(e.type).toEqual('event'); - } + const transport = makeNodeTransport({ + url: TEST_SERVER_URL, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); - try { - await transport.sendEvent(transactionPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for transaction requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(transactionPayload); - expect(e.type).toEqual('transaction'); - } + await transport.send(EVENT_ENVELOPE); + }); - mockHeaders = {}; - mockReturnCode = 200; + it.each([ + [RATE_LIMIT, 'rate_limit'], + [INVALID, 'invalid'], + [FAILED, 'failed'], + ])('should correctly reject bad server response (status %i)', async (serverStatusCode, expectedStatus) => { + await setupTestServer({ statusCode: serverStatusCode }); - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + await expect(transport.send(EVENT_ENVELOPE)).rejects.toEqual(expect.objectContaining({ status: expectedStatus })); + }); - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toEqual('success'); + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); - mock.mockRestore(); - }); + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); - test('back-off using x-sentry-rate-limits with single category', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-rate-limits': `${retryAfterSeconds}:error:scope`, - }; - const transport = createTransport({ dsn }); - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockReturnValueOnce(now) - // 1st event - _handleRateLimit - .mockReturnValueOnce(now) - // 2nd event - _isRateLimited - false (different category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 2nd event - _handleRateLimit - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _isRateLimited - false (different category - sessions) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _handleRateLimit - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 4th event - _isRateLimited - true - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 5th event - _isRateLimited - false - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 5th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); - mockHeaders = {}; - mockReturnCode = 200; - - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toEqual('success'); - - const sessionsRes = await transport.sendSession(sessionPayload); - expect(sessionsRes.status).toEqual('success'); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(eventPayload); - expect(e.type).toEqual('event'); - } + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); - mock.mockRestore(); + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); }); - test('back-off using x-sentry-rate-limits with multiple category', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-rate-limits': `${retryAfterSeconds}:error;transaction;session:scope`, - }; - const transport = createTransport({ dsn }); - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockReturnValueOnce(now) - // 1st event - _handleRateLimit - .mockReturnValueOnce(now) - // 2nd event - _isRateLimited - true (event category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _isRateLimited - true (sessions category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 4th event - _isRateLimited - true (transactions category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 5th event - _isRateLimited - false (event category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 5th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 6th event - _isRateLimited - false (sessions category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 6th event - handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 7th event - _isRateLimited - false (transaction category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 7th event - handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(eventPayload); - expect(e.type).toEqual('event'); - } + describe('proxy', () => { + it('can be configured through option', () => { + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); - try { - await transport.sendSession(sessionPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for session requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload.environment).toEqual(sessionPayload.environment); - expect(e.payload.release).toEqual(sessionPayload.release); - expect(e.payload.sid).toEqual(sessionPayload.sid); - expect(e.type).toEqual('session'); - } + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('http://example.com'); + }); - try { - await transport.sendEvent(transactionPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for transaction requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(transactionPayload); - expect(e.type).toEqual('transaction'); - } + it('can be configured through env variables option', () => { + process.env.http_proxy = 'http://example.com'; + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); - mockHeaders = {}; - mockReturnCode = 200; + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('http://example.com'); + delete process.env.http_proxy; + }); - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); + it('client options have priority over env variables', () => { + process.env.http_proxy = 'http://foo.com'; + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://bar.com', + }); - const sessionsRes = await transport.sendSession(sessionPayload); - expect(sessionsRes.status).toEqual('success'); + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('http://bar.com'); + delete process.env.http_proxy; + }); - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toEqual('success'); + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); - mock.mockRestore(); - }); + expect(httpProxyAgent).not.toHaveBeenCalled(); - test('back-off using x-sentry-rate-limits with missing categories should lock them all', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-rate-limits': `${retryAfterSeconds}::scope`, - }; - const transport = createTransport({ dsn }); - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockReturnValueOnce(now) - // 1st event - _handleRateLimit - .mockReturnValueOnce(now) - // 2nd event - _isRateLimited - true (event category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _isRateLimited - true (transaction category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 4th event - _isRateLimited - false (event category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 4th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 5th event - _isRateLimited - false (transaction category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 5th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + delete process.env.no_proxy; + }); - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(eventPayload); - expect(e.type).toEqual('event'); - } + it('no_proxy works with a port', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; - try { - await transport.sendEvent(transactionPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for transaction requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(transactionPayload); - expect(e.type).toEqual('transaction'); - } + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); - mockHeaders = {}; - mockReturnCode = 200; + expect(httpProxyAgent).not.toHaveBeenCalled(); - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); + delete process.env.no_proxy; + delete process.env.http_proxy; + }); - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toEqual('success'); + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; - mock.mockRestore(); - }); + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); - test('back-off using x-sentry-rate-limits with bogus categories should be dropped', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-rate-limits': `${retryAfterSeconds}:error;safegreg;eqwerw:scope`, - }; - const transport = createTransport({ dsn }); - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockReturnValueOnce(now) - // 1st event - _handleRateLimit - .mockReturnValueOnce(now) - // 2nd event - _isRateLimited - true (event category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _isRateLimited - false (transaction category) - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd Event - _handleRateLimit - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 4th event - _isRateLimited - false (event category) - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 4th event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + expect(httpProxyAgent).not.toHaveBeenCalled(); - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(eventPayload); - expect(e.type).toEqual('event'); - } + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); - mockHeaders = {}; - mockReturnCode = 200; + it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); - const transactionRes = await transport.sendEvent(transactionPayload); - expect(transactionRes.status).toEqual('success'); + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - const eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); - mock.mockRestore(); + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); }); - test('back-off using x-sentry-rate-limits should also trigger for 200 responses', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 200; - mockHeaders = { - 'x-sentry-rate-limits': `${retryAfterSeconds}:error;transaction:scope`, - }; - const transport = createTransport({ dsn }); - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // 1st event - _isRateLimited - false - .mockReturnValueOnce(now) - // 1st event - _handleRateLimit - .mockReturnValueOnce(now) - // 2nd event - _isRateLimited - true - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // 3rd event - _isRateLimited - false - .mockReturnValueOnce(now + retryAfterSeconds * 1000) - // 3rd event - _handleRateLimit - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - let eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); - - try { - await transport.sendEvent(eventPayload); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload).toEqual(eventPayload); - expect(e.type).toEqual('event'); - } + it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); - mockReturnCode = 200; - mockHeaders = {}; + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - eventRes = await transport.sendEvent(eventPayload); - expect(eventRes.status).toEqual('success'); + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); - mock.mockRestore(); + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + statusCode: SUCCESS, + }), + ); }); - test('transport options', async () => { - mockReturnCode = 200; - const transport = createTransport({ - dsn, - headers: { - a: 'b', + it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', }, }); - await transport.sendEvent({ - message: 'test', - }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(requestOptions.headers).toEqual(expect.objectContaining({ a: 'b' })); - }); + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - describe('proxy', () => { - test('can be configured through client option', async () => { - const transport = createTransport({ - dsn, - httpProxy: 'http://example.com:8080', - }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(false); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'http:', port: 8080, host: 'example.com' })); + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', }); - test('can be configured through env variables option', async () => { - process.env.http_proxy = 'http://example.com:8080'; - const transport = createTransport({ - dsn, - httpProxy: 'http://example.com:8080', - }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(false); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'http:', port: 8080, host: 'example.com' })); - delete process.env.http_proxy; - }); + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: SUCCESS, + }), + ); + }); - test('client options have priority over env variables', async () => { - process.env.http_proxy = 'http://env-example.com:8080'; - const transport = createTransport({ - dsn, - httpProxy: 'http://example.com:8080', - }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(false); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'http:', port: 8080, host: 'example.com' })); - delete process.env.http_proxy; + it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, }); - test('no_proxy allows for skipping specific hosts', async () => { - process.env.no_proxy = 'sentry.io'; - const transport = createTransport({ - dsn, - httpProxy: 'http://example.com:8080', - }); - expect(transport.client).toBeInstanceOf(http.Agent); - }); + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - test('no_proxy works with a port', async () => { - process.env.http_proxy = 'http://example.com:8080'; - process.env.no_proxy = 'sentry.io:8989'; - const transport = createTransport({ - dsn, - }); - expect(transport.client).toBeInstanceOf(http.Agent); - delete process.env.http_proxy; + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', }); - test('no_proxy works with multiple comma-separated hosts', async () => { - process.env.http_proxy = 'http://example.com:8080'; - process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; - const transport = createTransport({ - dsn, - }); - expect(transport.client).toBeInstanceOf(http.Agent); - delete process.env.http_proxy; - }); + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); }); }); diff --git a/packages/node/test/transports/https.test.ts b/packages/node/test/transports/https.test.ts index 8d7f4dd9aa65..28c37b7c966b 100644 --- a/packages/node/test/transports/https.test.ts +++ b/packages/node/test/transports/https.test.ts @@ -1,332 +1,396 @@ -import { Session } from '@sentry/hub'; -import { SessionAggregates, TransportOptions } from '@sentry/types'; -import { SentryError } from '@sentry/utils'; +import { createTransport } from '@sentry/core'; +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import * as http from 'http'; import * as https from 'https'; -import * as HttpsProxyAgent from 'https-proxy-agent'; - -import { HTTPSTransport } from '../../src/transports/https'; - -const mockSetEncoding = jest.fn(); -const dsn = 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622'; -const storePath = '/mysubpath/api/50622/store/'; -const envelopePath = '/mysubpath/api/50622/envelope/'; -const tunnel = 'https://hello.com/world'; -const sessionsPayload: SessionAggregates = { - attrs: { environment: 'test', release: '1.0' }, - aggregates: [{ started: '2021-03-17T16:00:00.000Z', exited: 1 }], -}; -let mockReturnCode = 200; -let mockHeaders = {}; - -jest.mock('fs', () => ({ - readFileSync(): string { - return 'mockedCert'; - }, -})); - -function createTransport(options: TransportOptions): HTTPSTransport { - const transport = new HTTPSTransport(options); - transport.module = { - request: jest.fn().mockImplementation((_options: any, callback: any) => ({ - end: () => { - callback({ - headers: mockHeaders, - setEncoding: mockSetEncoding, - statusCode: mockReturnCode, - }); - }, - on: jest.fn(), - })), + +import { makeNodeTransport } from '../../src/transports'; +import { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../src/transports/http-module'; +import testServerCerts from './test-server-certs'; + +jest.mock('@sentry/core', () => { + const actualCore = jest.requireActual('@sentry/core'); + return { + ...actualCore, + createTransport: jest.fn().mockImplementation(actualCore.createTransport), }; - return transport; -} +}); -function assertBasicOptions(options: any, useEnvelope: boolean = false): void { - expect(options.headers['X-Sentry-Auth']).toContain('sentry_version'); - expect(options.headers['X-Sentry-Auth']).toContain('sentry_client'); - expect(options.headers['X-Sentry-Auth']).toContain('sentry_key'); - expect(options.port).toEqual('8989'); - expect(options.path).toEqual(useEnvelope ? envelopePath : storePath); - expect(options.hostname).toEqual('sentry.io'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const httpProxyAgent = require('https-proxy-agent'); +jest.mock('https-proxy-agent', () => { + return jest.fn().mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); +}); + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; } -describe('HTTPSTransport', () => { - beforeEach(() => { - mockReturnCode = 200; - mockHeaders = {}; - jest.clearAllMocks(); - }); +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string) => void, +) { + testServer = https.createServer(testServerCerts, (req, res) => { + let body = ''; - test('send 200', async () => { - const transport = createTransport({ dsn }); - await transport.sendEvent({ - message: 'test', + req.on('data', data => { + body += data; }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(mockSetEncoding).toHaveBeenCalled(); - }); + req.on('end', () => { + requestInspector?.(req, body); + }); - test('send 400', async () => { - mockReturnCode = 400; - const transport = createTransport({ dsn }); + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); - try { - await transport.sendEvent({ - message: 'test', - }); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + // also terminate socket because keepalive hangs connection a bit + res.connection.end(); }); - test('send 200 session', async () => { - const transport = createTransport({ dsn }); - await transport.sendSession(new Session()); + testServer.listen(8099); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(mockSetEncoding).toHaveBeenCalled(); + return new Promise(resolve => { + testServer?.on('listening', resolve); }); +} - test('send 400 session', async () => { - mockReturnCode = 400; - const transport = createTransport({ dsn }); +const TEST_SERVER_URL = 'https://localhost:8099'; - try { - await transport.sendSession(new Session()); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } - }); +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); - test('send 200 request mode session', async () => { - const transport = createTransport({ dsn }); - await transport.sendSession(sessionsPayload); +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(mockSetEncoding).toHaveBeenCalled(); - }); +const unsafeHttpsModule: HTTPModule = { + request: jest + .fn() + .mockImplementation((options: https.RequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void) => { + return https.request({ ...options, rejectUnauthorized: false }, callback); + }), +}; - test('send 400 request mode session', async () => { - mockReturnCode = 400; - const transport = createTransport({ dsn }); +describe('makeNewHttpsTransport()', () => { + afterEach(() => { + jest.clearAllMocks(); - try { - await transport.sendSession(sessionsPayload); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions, true); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); + if (testServer) { + testServer.close(); } }); - test('send x-sentry-error header', async () => { - mockReturnCode = 429; - mockHeaders = { - 'x-sentry-error': 'test-failed', - }; - const transport = createTransport({ dsn }); + describe('.send()', () => { + it('should correctly return successful server response', async () => { + await setupTestServer({ statusCode: SUCCESS }); - try { - await transport.sendEvent({ - message: 'test', - }); - } catch (e) { - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode}): test-failed`)); - } - }); + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); - test('sends a request to tunnel if configured', async () => { - const transport = createTransport({ dsn, tunnel }); + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); - await transport.sendEvent({ - message: 'test', + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + await transport.send(EVENT_ENVELOPE); }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - expect(requestOptions.protocol).toEqual('https:'); - expect(requestOptions.hostname).toEqual('hello.com'); - expect(requestOptions.path).toEqual('/world'); - }); + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); - test('back-off using retry-after header', async () => { - const retryAfterSeconds = 10; - mockReturnCode = 429; - mockHeaders = { - 'retry-after': retryAfterSeconds, - }; - const transport = createTransport({ dsn }); - - const now = Date.now(); - const mock = jest - .spyOn(Date, 'now') - // Check for first event - .mockReturnValueOnce(now) - // Setting disabledUntil - .mockReturnValueOnce(now) - // Check for second event - .mockReturnValueOnce(now + (retryAfterSeconds / 2) * 1000) - // Check for third event - .mockReturnValueOnce(now + retryAfterSeconds * 1000); - - try { - await transport.sendEvent({ message: 'test' }); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + const transport = makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); - try { - await transport.sendEvent({ message: 'test' }); - } catch (e) { - expect(e.status).toEqual(429); - expect(e.reason).toEqual( - `Transport for event requests locked till ${new Date( - now + retryAfterSeconds * 1000, - )} due to too many requests.`, - ); - expect(e.payload.message).toEqual('test'); - expect(e.type).toEqual('event'); - } + await transport.send(EVENT_ENVELOPE); + }); - try { - await transport.sendEvent({ message: 'test' }); - } catch (e) { - expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); - } + it.each([ + [RATE_LIMIT, 'rate_limit'], + [INVALID, 'invalid'], + [FAILED, 'failed'], + ])('should correctly reject bad server response (status %i)', async (serverStatusCode, expectedStatus) => { + await setupTestServer({ statusCode: serverStatusCode }); - mock.mockRestore(); - }); + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + await expect(transport.send(EVENT_ENVELOPE)).rejects.toEqual(expect.objectContaining({ status: expectedStatus })); + }); - test('transport options', async () => { - mockReturnCode = 200; - const transport = createTransport({ - dsn, - headers: { - a: 'b', - }, + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); }); - await transport.sendEvent({ - message: 'test', + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - assertBasicOptions(requestOptions); - expect(requestOptions.headers).toEqual(expect.objectContaining({ a: 'b' })); - }); + it('should use `caCerts` option', async () => { + await setupTestServer({ statusCode: SUCCESS }); - describe('proxy', () => { - test('can be configured through client option', async () => { - const transport = createTransport({ - dsn, - httpsProxy: 'https://example.com:8080', + const transport = makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + caCerts: 'some cert', }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(true); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'https:', port: 8080, host: 'example.com' })); + + await transport.send(EVENT_ENVELOPE); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(unsafeHttpsModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + ca: 'some cert', + }), + expect.anything(), + ); }); + }); - test('can be configured through env variables option', async () => { - process.env.https_proxy = 'https://example.com:8080'; - const transport = createTransport({ - dsn, - httpsProxy: 'https://example.com:8080', + describe('proxy', () => { + it('can be configured through option', () => { + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(true); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'https:', port: 8080, host: 'example.com' })); - delete process.env.https_proxy; + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); }); - test('https proxies have priority in client option', async () => { - const transport = createTransport({ - dsn, - httpProxy: 'http://unsecure-example.com:8080', - httpsProxy: 'https://example.com:8080', + it('can be configured through env variables option (http)', () => { + process.env.http_proxy = 'https://example.com'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(true); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'https:', port: 8080, host: 'example.com' })); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); + delete process.env.http_proxy; }); - test('https proxies have priority in env variables', async () => { - process.env.http_proxy = 'http://unsecure-example.com:8080'; - process.env.https_proxy = 'https://example.com:8080'; - const transport = createTransport({ - dsn, + it('can be configured through env variables option (https)', () => { + process.env.https_proxy = 'https://example.com'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(true); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'https:', port: 8080, host: 'example.com' })); - delete process.env.http_proxy; + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); delete process.env.https_proxy; }); - test('client options have priority over env variables', async () => { - process.env.https_proxy = 'https://env-example.com:8080'; - const transport = createTransport({ - dsn, - httpsProxy: 'https://example.com:8080', + it('client options have priority over env variables', () => { + process.env.https_proxy = 'https://foo.com'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://bar.com', }); - const client = transport.client as unknown as { proxy: Record; secureProxy: boolean }; - expect(client).toBeInstanceOf(HttpsProxyAgent); - expect(client.secureProxy).toEqual(true); - expect(client.proxy).toEqual(expect.objectContaining({ protocol: 'https:', port: 8080, host: 'example.com' })); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://bar.com'); delete process.env.https_proxy; }); - test('no_proxy allows for skipping specific hosts', async () => { + it('no_proxy allows for skipping specific hosts', () => { process.env.no_proxy = 'sentry.io'; - const transport = createTransport({ - dsn, - httpsProxy: 'https://example.com:8080', + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', }); - expect(transport.client).toBeInstanceOf(https.Agent); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; }); - test('no_proxy works with a port', async () => { - process.env.https_proxy = 'https://example.com:8080'; + it('no_proxy works with a port', () => { + process.env.http_proxy = 'https://example.com:8080'; process.env.no_proxy = 'sentry.io:8989'; - const transport = createTransport({ - dsn, + + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', }); - expect(transport.client).toBeInstanceOf(https.Agent); - delete process.env.https_proxy; + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; }); - test('no_proxy works with multiple comma-separated hosts', async () => { + it('no_proxy works with multiple comma-separated hosts', () => { process.env.http_proxy = 'https://example.com:8080'; process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; - const transport = createTransport({ - dsn, + + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', }); - expect(transport.client).toBeInstanceOf(https.Agent); - delete process.env.https_proxy; + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; }); + }); - test('can configure tls certificate through client option', async () => { - mockReturnCode = 200; - const transport = createTransport({ - caCerts: './some/path.pem', - dsn, - }); - await transport.sendEvent({ - message: 'test', - }); - const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; - expect(requestOptions.ca).toEqual('mockedCert'); + it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + statusCode: SUCCESS, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: SUCCESS, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); }); }); diff --git a/packages/node/test/transports/new/http.test.ts b/packages/node/test/transports/new/http.test.ts deleted file mode 100644 index b3ce46d5a542..000000000000 --- a/packages/node/test/transports/new/http.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { createTransport } from '@sentry/core'; -import { EventEnvelope, EventItem } from '@sentry/types'; -import { createEnvelope, serializeEnvelope } from '@sentry/utils'; -import * as http from 'http'; - -// TODO(v7): We're renaming the imported file so this needs to be changed as well -import { makeNodeTransport } from '../../../src/transports/new'; - -jest.mock('@sentry/core', () => { - const actualCore = jest.requireActual('@sentry/core'); - return { - ...actualCore, - createTransport: jest.fn().mockImplementation(actualCore.createTransport), - }; -}); - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const httpProxyAgent = require('https-proxy-agent'); -jest.mock('https-proxy-agent', () => { - return jest.fn().mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); -}); - -const SUCCESS = 200; -const RATE_LIMIT = 429; -const INVALID = 400; -const FAILED = 500; - -interface TestServerOptions { - statusCode: number; - responseHeaders?: Record; -} - -let testServer: http.Server | undefined; - -function setupTestServer( - options: TestServerOptions, - requestInspector?: (req: http.IncomingMessage, body: string) => void, -) { - testServer = http.createServer((req, res) => { - let body = ''; - - req.on('data', data => { - body += data; - }); - - req.on('end', () => { - requestInspector?.(req, body); - }); - - res.writeHead(options.statusCode, options.responseHeaders); - res.end(); - - // also terminate socket because keepalive hangs connection a bit - res.connection.end(); - }); - - testServer.listen(18099); - - return new Promise(resolve => { - testServer?.on('listening', resolve); - }); -} - -const TEST_SERVER_URL = 'http://localhost:18099'; - -const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ - [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, -]); - -const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); - -describe('makeNewHttpTransport()', () => { - afterEach(() => { - jest.clearAllMocks(); - - if (testServer) { - testServer.close(); - } - }); - - describe('.send()', () => { - it('should correctly return successful server response', async () => { - await setupTestServer({ statusCode: SUCCESS }); - - const transport = makeNodeTransport({ url: TEST_SERVER_URL }); - const transportResponse = await transport.send(EVENT_ENVELOPE); - - expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); - }); - - it('should correctly send envelope to server', async () => { - await setupTestServer({ statusCode: SUCCESS }, (req, body) => { - expect(req.method).toBe('POST'); - expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); - }); - - const transport = makeNodeTransport({ url: TEST_SERVER_URL }); - await transport.send(EVENT_ENVELOPE); - }); - - it('should correctly send user-provided headers to server', async () => { - await setupTestServer({ statusCode: SUCCESS }, req => { - expect(req.headers).toEqual( - expect.objectContaining({ - // node http module lower-cases incoming headers - 'x-some-custom-header-1': 'value1', - 'x-some-custom-header-2': 'value2', - }), - ); - }); - - const transport = makeNodeTransport({ - url: TEST_SERVER_URL, - headers: { - 'X-Some-Custom-Header-1': 'value1', - 'X-Some-Custom-Header-2': 'value2', - }, - }); - - await transport.send(EVENT_ENVELOPE); - }); - - it.each([ - [RATE_LIMIT, 'rate_limit'], - [INVALID, 'invalid'], - [FAILED, 'failed'], - ])('should correctly reject bad server response (status %i)', async (serverStatusCode, expectedStatus) => { - await setupTestServer({ statusCode: serverStatusCode }); - - const transport = makeNodeTransport({ url: TEST_SERVER_URL }); - await expect(transport.send(EVENT_ENVELOPE)).rejects.toEqual(expect.objectContaining({ status: expectedStatus })); - }); - - it('should resolve when server responds with rate limit header and status code 200', async () => { - await setupTestServer({ - statusCode: SUCCESS, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - const transport = makeNodeTransport({ url: TEST_SERVER_URL }); - const transportResponse = await transport.send(EVENT_ENVELOPE); - - expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); - }); - - it('should resolve when server responds with rate limit header and status code 200', async () => { - await setupTestServer({ - statusCode: SUCCESS, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - const transport = makeNodeTransport({ url: TEST_SERVER_URL }); - const transportResponse = await transport.send(EVENT_ENVELOPE); - - expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); - }); - }); - - describe('proxy', () => { - it('can be configured through option', () => { - makeNodeTransport({ - url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - proxy: 'http://example.com', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('http://example.com'); - }); - - it('can be configured through env variables option', () => { - process.env.http_proxy = 'http://example.com'; - makeNodeTransport({ - url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('http://example.com'); - delete process.env.http_proxy; - }); - - it('client options have priority over env variables', () => { - process.env.http_proxy = 'http://foo.com'; - makeNodeTransport({ - url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - proxy: 'http://bar.com', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('http://bar.com'); - delete process.env.http_proxy; - }); - - it('no_proxy allows for skipping specific hosts', () => { - process.env.no_proxy = 'sentry.io'; - makeNodeTransport({ - url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - proxy: 'http://example.com', - }); - - expect(httpProxyAgent).not.toHaveBeenCalled(); - - delete process.env.no_proxy; - }); - - it('no_proxy works with a port', () => { - process.env.http_proxy = 'http://example.com:8080'; - process.env.no_proxy = 'sentry.io:8989'; - - makeNodeTransport({ - url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).not.toHaveBeenCalled(); - - delete process.env.no_proxy; - delete process.env.http_proxy; - }); - - it('no_proxy works with multiple comma-separated hosts', () => { - process.env.http_proxy = 'http://example.com:8080'; - process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; - - makeNodeTransport({ - url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).not.toHaveBeenCalled(); - - delete process.env.no_proxy; - delete process.env.http_proxy; - }); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { - await setupTestServer({ - statusCode: RATE_LIMIT, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - makeNodeTransport({ url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': '2700', - 'x-sentry-rate-limits': '60::organization, 2700::organization', - }, - statusCode: RATE_LIMIT, - }), - ); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { - await setupTestServer({ - statusCode: SUCCESS, - }); - - makeNodeTransport({ url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': null, - 'x-sentry-rate-limits': null, - }, - statusCode: SUCCESS, - }), - ); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { - await setupTestServer({ - statusCode: SUCCESS, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - makeNodeTransport({ url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': '2700', - 'x-sentry-rate-limits': '60::organization, 2700::organization', - }, - statusCode: SUCCESS, - }), - ); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { - await setupTestServer({ - statusCode: RATE_LIMIT, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - makeNodeTransport({ url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': '2700', - 'x-sentry-rate-limits': '60::organization, 2700::organization', - }, - statusCode: RATE_LIMIT, - }), - ); - }); -}); diff --git a/packages/node/test/transports/new/https.test.ts b/packages/node/test/transports/new/https.test.ts deleted file mode 100644 index 7784e16c65df..000000000000 --- a/packages/node/test/transports/new/https.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { createTransport } from '@sentry/core'; -import { EventEnvelope, EventItem } from '@sentry/types'; -import { createEnvelope, serializeEnvelope } from '@sentry/utils'; -import * as http from 'http'; -import * as https from 'https'; - -import { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../../src/transports/base/http-module'; -// TODO(v7): We're renaming the imported file so this needs to be changed as well -import { makeNodeTransport } from '../../../src/transports/new'; -import testServerCerts from './test-server-certs'; - -jest.mock('@sentry/core', () => { - const actualCore = jest.requireActual('@sentry/core'); - return { - ...actualCore, - createTransport: jest.fn().mockImplementation(actualCore.createTransport), - }; -}); - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const httpProxyAgent = require('https-proxy-agent'); -jest.mock('https-proxy-agent', () => { - return jest.fn().mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); -}); - -const SUCCESS = 200; -const RATE_LIMIT = 429; -const INVALID = 400; -const FAILED = 500; - -interface TestServerOptions { - statusCode: number; - responseHeaders?: Record; -} - -let testServer: http.Server | undefined; - -function setupTestServer( - options: TestServerOptions, - requestInspector?: (req: http.IncomingMessage, body: string) => void, -) { - testServer = https.createServer(testServerCerts, (req, res) => { - let body = ''; - - req.on('data', data => { - body += data; - }); - - req.on('end', () => { - requestInspector?.(req, body); - }); - - res.writeHead(options.statusCode, options.responseHeaders); - res.end(); - - // also terminate socket because keepalive hangs connection a bit - res.connection.end(); - }); - - testServer.listen(8099); - - return new Promise(resolve => { - testServer?.on('listening', resolve); - }); -} - -const TEST_SERVER_URL = 'https://localhost:8099'; - -const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ - [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, -]); - -const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); - -const unsafeHttpsModule: HTTPModule = { - request: jest - .fn() - .mockImplementation((options: https.RequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void) => { - return https.request({ ...options, rejectUnauthorized: false }, callback); - }), -}; - -describe('makeNewHttpsTransport()', () => { - afterEach(() => { - jest.clearAllMocks(); - - if (testServer) { - testServer.close(); - } - }); - - describe('.send()', () => { - it('should correctly return successful server response', async () => { - await setupTestServer({ statusCode: SUCCESS }); - - const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const transportResponse = await transport.send(EVENT_ENVELOPE); - - expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); - }); - - it('should correctly send envelope to server', async () => { - await setupTestServer({ statusCode: SUCCESS }, (req, body) => { - expect(req.method).toBe('POST'); - expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); - }); - - const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - await transport.send(EVENT_ENVELOPE); - }); - - it('should correctly send user-provided headers to server', async () => { - await setupTestServer({ statusCode: SUCCESS }, req => { - expect(req.headers).toEqual( - expect.objectContaining({ - // node http module lower-cases incoming headers - 'x-some-custom-header-1': 'value1', - 'x-some-custom-header-2': 'value2', - }), - ); - }); - - const transport = makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: TEST_SERVER_URL, - headers: { - 'X-Some-Custom-Header-1': 'value1', - 'X-Some-Custom-Header-2': 'value2', - }, - }); - - await transport.send(EVENT_ENVELOPE); - }); - - it.each([ - [RATE_LIMIT, 'rate_limit'], - [INVALID, 'invalid'], - [FAILED, 'failed'], - ])('should correctly reject bad server response (status %i)', async (serverStatusCode, expectedStatus) => { - await setupTestServer({ statusCode: serverStatusCode }); - - const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - await expect(transport.send(EVENT_ENVELOPE)).rejects.toEqual(expect.objectContaining({ status: expectedStatus })); - }); - - it('should resolve when server responds with rate limit header and status code 200', async () => { - await setupTestServer({ - statusCode: SUCCESS, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const transportResponse = await transport.send(EVENT_ENVELOPE); - - expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); - }); - - it('should resolve when server responds with rate limit header and status code 200', async () => { - await setupTestServer({ - statusCode: SUCCESS, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const transportResponse = await transport.send(EVENT_ENVELOPE); - - expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); - }); - - it('should use `caCerts` option', async () => { - await setupTestServer({ statusCode: SUCCESS }); - - const transport = makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: TEST_SERVER_URL, - caCerts: 'some cert', - }); - - await transport.send(EVENT_ENVELOPE); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(unsafeHttpsModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - ca: 'some cert', - }), - expect.anything(), - ); - }); - }); - - describe('proxy', () => { - it('can be configured through option', () => { - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - proxy: 'https://example.com', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); - }); - - it('can be configured through env variables option (http)', () => { - process.env.http_proxy = 'https://example.com'; - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); - delete process.env.http_proxy; - }); - - it('can be configured through env variables option (https)', () => { - process.env.https_proxy = 'https://example.com'; - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); - delete process.env.https_proxy; - }); - - it('client options have priority over env variables', () => { - process.env.https_proxy = 'https://foo.com'; - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - proxy: 'https://bar.com', - }); - - expect(httpProxyAgent).toHaveBeenCalledTimes(1); - expect(httpProxyAgent).toHaveBeenCalledWith('https://bar.com'); - delete process.env.https_proxy; - }); - - it('no_proxy allows for skipping specific hosts', () => { - process.env.no_proxy = 'sentry.io'; - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - proxy: 'https://example.com', - }); - - expect(httpProxyAgent).not.toHaveBeenCalled(); - - delete process.env.no_proxy; - }); - - it('no_proxy works with a port', () => { - process.env.http_proxy = 'https://example.com:8080'; - process.env.no_proxy = 'sentry.io:8989'; - - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).not.toHaveBeenCalled(); - - delete process.env.no_proxy; - delete process.env.http_proxy; - }); - - it('no_proxy works with multiple comma-separated hosts', () => { - process.env.http_proxy = 'https://example.com:8080'; - process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; - - makeNodeTransport({ - httpModule: unsafeHttpsModule, - url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', - }); - - expect(httpProxyAgent).not.toHaveBeenCalled(); - - delete process.env.no_proxy; - delete process.env.http_proxy; - }); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { - await setupTestServer({ - statusCode: RATE_LIMIT, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': '2700', - 'x-sentry-rate-limits': '60::organization, 2700::organization', - }, - statusCode: RATE_LIMIT, - }), - ); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { - await setupTestServer({ - statusCode: SUCCESS, - }); - - makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': null, - 'x-sentry-rate-limits': null, - }, - statusCode: SUCCESS, - }), - ); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { - await setupTestServer({ - statusCode: SUCCESS, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': '2700', - 'x-sentry-rate-limits': '60::organization, 2700::organization', - }, - statusCode: SUCCESS, - }), - ); - }); - - it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { - await setupTestServer({ - statusCode: RATE_LIMIT, - responseHeaders: { - 'Retry-After': '2700', - 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', - }, - }); - - makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); - const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; - - const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), - category: 'error', - }); - - await expect(executorResult).resolves.toEqual( - expect.objectContaining({ - headers: { - 'retry-after': '2700', - 'x-sentry-rate-limits': '60::organization, 2700::organization', - }, - statusCode: RATE_LIMIT, - }), - ); - }); -}); diff --git a/packages/node/test/transports/new/test-server-certs.ts b/packages/node/test/transports/test-server-certs.ts similarity index 100% rename from packages/node/test/transports/new/test-server-certs.ts rename to packages/node/test/transports/test-server-certs.ts diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index b1cadf971197..9b91c6712e06 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -5,7 +5,7 @@ import { ClientOptions } from './options'; import { Scope } from './scope'; import { Session, SessionAggregates } from './session'; import { Severity, SeverityLevel } from './severity'; -import { NewTransport } from './transport'; +import { Transport } from './transport'; /** * User-Facing Sentry SDK Client. @@ -72,7 +72,7 @@ export interface Client { * * @returns The transport. */ - getTransport(): NewTransport | undefined; + getTransport(): Transport | undefined; /** * Flush the event queue and set the client to `enabled = false`. See {@link Client.flush}. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3c066e3cc0bf..ab2592cfe02e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -65,14 +65,12 @@ export type { Thread } from './thread'; export type { Outcome, Transport, - TransportOptions, TransportCategory, TransportRequest, TransportMakeRequestResponse, TransportResponse, InternalBaseTransportOptions, BaseTransportOptions, - NewTransport, TransportRequestExecutor, } from './transport'; export type { User, UserFeedback } from './user'; diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index 61188d6c099f..b310b8e1799e 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -5,7 +5,7 @@ import { CaptureContext } from './scope'; import { SdkMetadata } from './sdkmetadata'; import { StackLineParser, StackParser } from './stacktrace'; import { SamplingContext } from './transaction'; -import { BaseTransportOptions, NewTransport } from './transport'; +import { BaseTransportOptions, Transport } from './transport'; export interface ClientOptions { /** @@ -61,7 +61,7 @@ export interface ClientOptions NewTransport; + transport: (transportOptions: TO) => Transport; /** * A stack parser implementation @@ -221,7 +221,7 @@ export interface Options /** * Transport object that should be used to send events to Sentry */ - transport?: (transportOptions: TO) => NewTransport; + transport?: (transportOptions: TO) => Transport; /** * A stack parser implementation or an array of stack line parsers diff --git a/packages/types/src/transport.ts b/packages/types/src/transport.ts index 1250d4cb01d5..1c2449a386b5 100644 --- a/packages/types/src/transport.ts +++ b/packages/types/src/transport.ts @@ -1,11 +1,5 @@ -import { DsnLike } from './dsn'; import { Envelope } from './envelope'; -import { Event } from './event'; import { EventStatus } from './eventstatus'; -import { SentryRequestType } from './request'; -import { Response } from './response'; -import { SdkMetadata } from './sdkmetadata'; -import { Session, SessionAggregates } from './session'; export type Outcome = | 'before_send' @@ -48,69 +42,9 @@ export interface BaseTransportOptions extends InternalBaseTransportOptions { url: string; } -export interface NewTransport { +export interface Transport { send(request: Envelope): PromiseLike; flush(timeout?: number): PromiseLike; } export type TransportRequestExecutor = (request: TransportRequest) => PromiseLike; - -/** Transport used sending data to Sentry */ -export interface Transport { - /** - * Sends the event to the Store endpoint in Sentry. - * - * @param event Event that should be sent to Sentry. - */ - sendEvent(event: Event): PromiseLike; - - /** - * Sends the session to the Envelope endpoint in Sentry. - * - * @param session Session that should be sent to Sentry | Session Aggregates that should be sent to Sentry. - */ - sendSession?(session: Session | SessionAggregates): PromiseLike; - - /** - * Wait for all events to be sent or the timeout to expire, whichever comes first. - * - * @param timeout Maximum time in ms the transport should wait for events to be flushed. Omitting this parameter will - * cause the transport to wait until all events are sent before resolving the promise. - * @returns A promise that will resolve with `true` if all events are sent before the timeout, or `false` if there are - * still events in the queue when the timeout is reached. - */ - close(timeout?: number): PromiseLike; - - /** - * Increment the counter for the specific client outcome - */ - recordLostEvent?(type: Outcome, category: SentryRequestType): void; -} - -/** JSDoc */ -export type TransportClass = new (options: TransportOptions) => T; - -/** JSDoc */ -export interface TransportOptions { - /** Sentry DSN */ - dsn: DsnLike; - /** Define custom headers */ - headers?: { [key: string]: string }; - /** Set a HTTP proxy that should be used for outbound requests. */ - httpProxy?: string; - /** Set a HTTPS proxy that should be used for outbound requests. */ - httpsProxy?: string; - /** HTTPS proxy certificates path */ - caCerts?: string; - /** Fetch API init parameters */ - fetchParameters?: { [key: string]: string }; - /** The envelope tunnel to use. */ - tunnel?: string; - /** Send SDK Client Reports. Enabled by default. */ - sendClientReports?: boolean; - /** - * Set of metadata about the SDK that can be internally used to enhance envelopes and events, - * and provide additional data about every request. - * */ - _metadata?: SdkMetadata; -}