diff --git a/packages/browser/src/base_notifier.ts b/packages/browser/src/base_notifier.ts index 34cb2f1ad..65ef10f46 100644 --- a/packages/browser/src/base_notifier.ts +++ b/packages/browser/src/base_notifier.ts @@ -22,6 +22,7 @@ import { QueueMetric, QueuesStats } from './queues'; import { RouteMetric, RoutesBreakdowns, RoutesStats } from './routes'; import { NOTIFIER_NAME, NOTIFIER_VERSION, NOTIFIER_URL } from './version'; import { PerformanceFilter } from './filter/performance_filter'; +import { RemoteSettings } from './remote_settings'; export class BaseNotifier { routes: Routes; @@ -46,10 +47,17 @@ export class BaseNotifier { this._opt = opt; this._opt.host = this._opt.host || 'https://api.airbrake.io'; + this._opt.remoteConfigHost = + this._opt.remoteConfigHost || 'https://notifier-configs.airbrake.io'; + this._opt.apmHost = this._opt.apmHost || 'https://api.airbrake.io'; this._opt.timeout = this._opt.timeout || 10000; this._opt.keysBlocklist = this._opt.keysBlocklist || [/password/, /secret/]; this._url = `${this._opt.host}/api/v3/projects/${this._opt.projectId}/notices?key=${this._opt.projectKey}`; + this._opt.errorNotifications = this._opt.errorNotifications !== false; + this._opt.performanceStats = this._opt.performanceStats !== false; + this._opt.remoteConfig = this._opt.remoteConfig !== false; + this._processor = this._opt.processor || espProcessor; this._requester = makeRequester(this._opt); @@ -73,6 +81,11 @@ export class BaseNotifier { this.routes = new Routes(this); this.queues = new Queues(this); this.queries = new QueriesStats(this._opt); + + if (this._opt.remoteConfig) { + const pollerId = new RemoteSettings(this._opt).poll(); + this._onClose.push(() => clearInterval(pollerId)); + } } close(): void { @@ -114,6 +127,15 @@ export class BaseNotifier { err = { error: err }; } + if (!this._opt.errorNotifications) { + notice.error = new Error( + `airbrake: not sending this error, errorNotifications is disabled err=${JSON.stringify( + err.error + )}` + ); + return Promise.resolve(notice); + } + if (!err.error) { notice.error = new Error( `airbrake: got err=${JSON.stringify(err.error)}, wanted an Error` @@ -227,11 +249,13 @@ class Routes { _notifier: BaseNotifier; _routes: RoutesStats; _breakdowns: RoutesBreakdowns; + _opt: IOptions; constructor(notifier: BaseNotifier) { this._notifier = notifier; this._routes = new RoutesStats(notifier._opt); this._breakdowns = new RoutesBreakdowns(notifier._opt); + this._opt = notifier._opt; } start( @@ -242,6 +266,10 @@ class Routes { ): RouteMetric { const metric = new RouteMetric(method, route, statusCode, contentType); + if (!this._opt.performanceStats) { + return metric; + } + const scope = this._notifier.scope().clone(); scope.setContext({ httpMethod: method, route }); scope.setRouteMetric(metric); @@ -251,7 +279,12 @@ class Routes { } notify(req: RouteMetric): void { + if (!this._opt.performanceStats) { + return; + } + req.end(); + for (const performanceFilter of this._notifier._performanceFilters) { if (performanceFilter(req) === null) { return; @@ -265,15 +298,21 @@ class Routes { class Queues { _notifier: BaseNotifier; _queues: QueuesStats; + _opt: IOptions; constructor(notifier: BaseNotifier) { this._notifier = notifier; this._queues = new QueuesStats(notifier._opt); + this._opt = notifier._opt; } start(queue: string): QueueMetric { const metric = new QueueMetric(queue); + if (!this._opt.performanceStats) { + return metric; + } + const scope = this._notifier.scope().clone(); scope.setContext({ queue }); scope.setQueueMetric(metric); @@ -283,6 +322,10 @@ class Queues { } notify(q: QueueMetric): void { + if (!this._opt.performanceStats) { + return; + } + q.end(); this._queues.notify(q); } diff --git a/packages/browser/src/http_req/api.ts b/packages/browser/src/http_req/api.ts index 75374f70f..e8fc30731 100644 --- a/packages/browser/src/http_req/api.ts +++ b/packages/browser/src/http_req/api.ts @@ -1,8 +1,9 @@ export interface IHttpRequest { method: string; url: string; - body: string; + body?: string; timeout?: number; + headers?: any; } export interface IHttpResponse { diff --git a/packages/browser/src/http_req/fetch.ts b/packages/browser/src/http_req/fetch.ts index ff4336c53..746527ef2 100644 --- a/packages/browser/src/http_req/fetch.ts +++ b/packages/browser/src/http_req/fetch.ts @@ -13,6 +13,7 @@ export function request(req: IHttpRequest): Promise { let opt = { method: req.method, body: req.body, + headers: req.headers, }; return fetch(req.url, opt).then((resp: Response) => { if (resp.status === 401) { diff --git a/packages/browser/src/options.ts b/packages/browser/src/options.ts index c9343ed33..b1b539473 100644 --- a/packages/browser/src/options.ts +++ b/packages/browser/src/options.ts @@ -18,11 +18,15 @@ export interface IOptions { projectKey: string; environment?: string; host?: string; + apmHost?: string; + remoteConfigHost?: string; + remoteConfig?: boolean; timeout?: number; keysBlocklist?: any[]; processor?: Processor; reporter?: Reporter; instrumentation?: IInstrumentationOptions; + errorNotifications?: boolean; performanceStats?: boolean; request?: request.RequestAPI< diff --git a/packages/browser/src/queries.ts b/packages/browser/src/queries.ts index a4ea564d9..f73b5b80d 100644 --- a/packages/browser/src/queries.ts +++ b/packages/browser/src/queries.ts @@ -59,6 +59,10 @@ export class QueriesStats { return; } + if (!this._opt.performanceStats) { + return; + } + let ms = q._duration(); const minute = 60 * 1000; diff --git a/packages/browser/src/queues.ts b/packages/browser/src/queues.ts index d0582efca..95aa61a74 100644 --- a/packages/browser/src/queues.ts +++ b/packages/browser/src/queues.ts @@ -39,6 +39,10 @@ export class QueuesStats { return; } + if (!this._opt.performanceStats) { + return; + } + let ms = q._duration(); if (ms === 0) { ms = 0.00001; diff --git a/packages/browser/src/remote_settings.ts b/packages/browser/src/remote_settings.ts new file mode 100644 index 000000000..72a030682 --- /dev/null +++ b/packages/browser/src/remote_settings.ts @@ -0,0 +1,204 @@ +import { makeRequester, Requester } from './http_req'; +import { IOptions } from './options'; +import { NOTIFIER_NAME, NOTIFIER_VERSION } from './version'; + +// API version to poll. +const API_VER = '2020-06-18'; + +// How frequently we should poll the config API. +const DEFAULT_INTERVAL = 600000; // 10 minutes + +const NOTIFIER_INFO = { + notifier_name: NOTIFIER_NAME, + notifier_version: NOTIFIER_VERSION, + os: + typeof window !== 'undefined' && + window.navigator && + window.navigator.userAgent + ? window.navigator.userAgent + : undefined, + language: 'JavaScript', +}; + +// Remote config settings. +const ERROR_SETTING = 'errors'; +const APM_SETTING = 'apm'; + +interface IRemoteConfig { + project_id: number; + updated_at: number; + poll_sec: number; + config_route: string; + settings: IRemoteConfigSetting[]; +} + +interface IRemoteConfigSetting { + name: string; + enabled: boolean; + endpoint: string; +} + +export class RemoteSettings { + _opt: IOptions; + _requester: Requester; + _data: SettingsData; + _origErrorNotifications: boolean; + _origPerformanceStats: boolean; + + constructor(opt: IOptions) { + this._opt = opt; + this._requester = makeRequester(opt); + + this._data = new SettingsData(opt.projectId, { + project_id: null, + poll_sec: 0, + updated_at: 0, + config_route: '', + settings: [], + }); + + this._origErrorNotifications = opt.errorNotifications; + this._origPerformanceStats = opt.performanceStats; + } + + poll(): any { + // First request is immediate. When it's done, we cancel it since we want to + // change interval time to the default value. + const pollerId = setInterval(() => { + this._doRequest(); + clearInterval(pollerId); + }, 0); + + // Second fetch is what always runs in background. + return setInterval(this._doRequest.bind(this), DEFAULT_INTERVAL); + } + + _doRequest(): void { + this._requester(this._requestParams(this._opt)).then((resp) => { + this._data.merge(resp.json); + + this._opt.host = this._data.errorHost(); + this._opt.apmHost = this._data.apmHost(); + + this._processErrorNotifications(this._data); + this._processPerformanceStats(this._data); + }); + } + + _requestParams(opt: IOptions): any { + return { + method: 'GET', + url: this._pollUrl(opt), + headers: { + Accept: 'application/json', + 'Cache-Control': 'no-cache,no-store', + }, + }; + } + + _pollUrl(opt: IOptions): string { + const url = new URL(this._data.configRoute(opt.remoteConfigHost)); + + for (const [key, value] of Object.entries(NOTIFIER_INFO)) { + url.searchParams.append(key, value); + } + + return url.toString(); + } + + _processErrorNotifications(data: SettingsData): void { + if (!this._origErrorNotifications) { + return; + } + this._opt.errorNotifications = data.errorNotifications(); + } + + _processPerformanceStats(data: SettingsData): void { + if (!this._origPerformanceStats) { + return; + } + this._opt.performanceStats = data.performanceStats(); + } +} + +export class SettingsData { + _projectId: number; + _data: IRemoteConfig; + + constructor(projectId: number, data: IRemoteConfig) { + this._projectId = projectId; + this._data = data; + } + + merge(other: IRemoteConfig) { + this._data = { ...this._data, ...other }; + } + + configRoute(remoteConfigHost: string): string { + const host = remoteConfigHost.replace(/\/$/, ''); + const configRoute = this._data.config_route; + + if ( + configRoute === null || + configRoute === undefined || + configRoute === '' + ) { + return `${host}/${API_VER}/config/${this._projectId}/config.json`; + } else { + return `${host}/${configRoute}`; + } + } + + errorNotifications(): boolean { + const s = this._findSetting(ERROR_SETTING); + if (s === null) { + return true; + } + + return s.enabled; + } + + performanceStats(): boolean { + const s = this._findSetting(APM_SETTING); + if (s === null) { + return true; + } + + return s.enabled; + } + + errorHost(): string { + const s = this._findSetting(ERROR_SETTING); + if (s === null) { + return null; + } + + return s.endpoint; + } + + apmHost(): string { + const s = this._findSetting(APM_SETTING); + if (s === null) { + return null; + } + + return s.endpoint; + } + + _findSetting(name: string): IRemoteConfigSetting { + const settings = this._data.settings; + if (settings === null || settings === undefined) { + return null; + } + + const setting = settings.find((s) => { + return s.name === name; + }); + + if (setting === undefined) { + return null; + } + + return setting; + } +} diff --git a/packages/browser/src/routes.ts b/packages/browser/src/routes.ts index 792d6151e..f2dd49d94 100644 --- a/packages/browser/src/routes.ts +++ b/packages/browser/src/routes.ts @@ -54,6 +54,10 @@ export class RoutesStats { return; } + if (!this._opt.performanceStats) { + return; + } + let ms = req._duration(); const minute = 60 * 1000; @@ -144,6 +148,10 @@ export class RoutesBreakdowns { return; } + if (!this._opt.performanceStats) { + return; + } + if ( req.statusCode < 200 || (req.statusCode >= 300 && req.statusCode < 400) || diff --git a/packages/browser/tests/client.test.js b/packages/browser/tests/client.test.js index 6f3d34b42..d268798ad 100644 --- a/packages/browser/tests/client.test.js +++ b/packages/browser/tests/client.test.js @@ -16,6 +16,7 @@ describe('Notifier config', () => { projectId: 1, projectKey: 'abc', reporter, + remoteConfig: false, }); client.notify(err); @@ -28,6 +29,7 @@ describe('Notifier config', () => { projectKey: 'abc', reporter, environment: 'production', + remoteConfig: false, }); client.notify(err); @@ -44,6 +46,7 @@ describe('Notifier config', () => { projectKey: 'abc', reporter, keysBlocklist, + remoteConfig: false, }); client.notify({ @@ -87,6 +90,7 @@ describe('Notifier', () => { projectId: 1, projectKey: 'abc', reporter, + remoteConfig: false, }); }); @@ -153,7 +157,7 @@ describe('Notifier', () => { let err = notice.errors[0]; expect(err.type).toBe('SecurityError'); expect(err.message).toBe( - 'Blocked a frame with origin "https://airbrake.io" from accessing a cross-origin frame.', + 'Blocked a frame with origin "https://airbrake.io" from accessing a cross-origin frame.' ); }); }); @@ -170,7 +174,7 @@ describe('Notifier', () => { let err = notice.errors[0]; expect(err.type).toBe('$injector:undef'); expect(err.message).toBe( - `Provider '$exceptionHandler' must return a value from $get factory method.\nhttp://errors.angularjs.org/1.4.3/$injector/undef?p0=%24exceptionHandler`, + `Provider '$exceptionHandler' must return a value from $get factory method.\nhttp://errors.angularjs.org/1.4.3/$injector/undef?p0=%24exceptionHandler` ); }); }); @@ -202,6 +206,34 @@ describe('Notifier', () => { expect(reporter.mock.calls.length).toBe(1); }); + describe('when errorNotifications is disabled', () => { + beforeEach(() => { + client = new Notifier({ + projectId: 1, + projectKey: 'abc', + reporter, + environment: 'production', + errorNotifications: false, + remoteConfig: false, + }); + }); + + it('does not call reporter', () => { + client.notify(theErr); + expect(reporter.mock.calls.length).toBe(0); + }); + + it('returns promise and resolves it', (done) => { + let promise = client.notify(theErr); + let onResolved = jest.fn(); + promise.then(onResolved); + setTimeout(() => { + expect(onResolved.mock.calls.length).toBe(1); + done(); + }, 0); + }); + }); + it('returns promise and resolves it', (done) => { let promise = client.notify(theErr); let onResolved = jest.fn(); @@ -219,7 +251,7 @@ describe('Notifier', () => { let promise = client.notify(theErr); promise.then((notice) => { expect(notice.error.toString()).toBe( - 'Error: airbrake: error is filtered', + 'Error: airbrake: error is filtered' ); done(); }); @@ -231,7 +263,7 @@ describe('Notifier', () => { promise.then((notice) => { expect(notice.error.toString()).toBe( - 'Error: airbrake: got err="", wanted an Error', + 'Error: airbrake: got err="", wanted an Error' ); done(); }); @@ -338,7 +370,7 @@ describe('Notifier', () => { promise.then((notice) => { expect(notice.error.toString()).toBe( - 'Error: airbrake: got err=null, wanted an Error', + 'Error: airbrake: got err=null, wanted an Error' ); done(); }); @@ -573,4 +605,26 @@ describe('Notifier', () => { }); }); }); + + describe('errorNotifications', () => { + it('is set to true by default when it is not specified', () => { + client = new Notifier({ + projectId: 1, + projectKey: 'abc', + remoteConfig: false, + }); + expect(client._opt.errorNotifications).toBe(true); + }); + }); + + describe('performanceStats', () => { + it('is set to true by default when it is not specified', () => { + client = new Notifier({ + projectId: 1, + projectKey: 'abc', + remoteConfig: false, + }); + expect(client._opt.performanceStats).toBe(true); + }); + }); }); diff --git a/packages/browser/tests/historian.test.js b/packages/browser/tests/historian.test.js index 50a792523..73702678c 100644 --- a/packages/browser/tests/historian.test.js +++ b/packages/browser/tests/historian.test.js @@ -31,6 +31,7 @@ describe('instrumentation', () => { projectKey: 'abc', processor, reporter, + remoteConfig: false, }); }); diff --git a/packages/browser/tests/remote_settings.test.js b/packages/browser/tests/remote_settings.test.js new file mode 100644 index 000000000..0d0ffd05b --- /dev/null +++ b/packages/browser/tests/remote_settings.test.js @@ -0,0 +1,178 @@ +import { SettingsData } from '../src/remote_settings'; + +describe('SettingsData', () => { + describe('merge', () => { + it('merges JSON with a SettingsData', () => { + const disabledApm = { settings: [{ name: 'apm', enabled: false }] }; + const enabledApm = { settings: [{ name: 'apm', enabled: true }] }; + + const s = new SettingsData(1, disabledApm); + s.merge(enabledApm); + + expect(s._data).toMatchObject(enabledApm); + }); + }); + + describe('configRoute', () => { + describe('when config_route in JSON is null', () => { + it('returns the default route', () => { + const s = new SettingsData(1, { config_route: null }); + expect(s.configRoute('http://example.com/')).toMatch( + 'http://example.com/2020-06-18/config/1/config.json' + ); + }); + }); + + describe('when config_route in JSON is undefined', () => { + it('returns the default route', () => { + const s = new SettingsData(1, { config_route: undefined }); + expect(s.configRoute('http://example.com/')).toMatch( + 'http://example.com/2020-06-18/config/1/config.json' + ); + }); + }); + + describe('when config_route in JSON is an empty string', () => { + it('returns the default route', () => { + const s = new SettingsData(1, { config_route: '' }); + expect(s.configRoute('http://example.com/')).toMatch( + 'http://example.com/2020-06-18/config/1/config.json' + ); + }); + }); + + describe('when config_route in JSON is specified', () => { + it('returns the specified route', () => { + const s = new SettingsData(1, { config_route: 'ROUTE/cfg.json' }); + expect(s.configRoute('http://example.com/')).toMatch( + 'http://example.com/ROUTE/cfg.json' + ); + }); + }); + + describe('when the given host does not contain an ending slash', () => { + it('returns the specified route', () => { + const s = new SettingsData(1, { config_route: 'ROUTE/cfg.json' }); + expect(s.configRoute('http://example.com')).toMatch( + 'http://example.com/ROUTE/cfg.json' + ); + }); + }); + }); + + describe('errorNotifications', () => { + describe('when the "errors" setting exists', () => { + describe('and when it is enabled', () => { + it('returns true', () => { + const s = new SettingsData(1, { + settings: [{ name: 'errors', enabled: true }], + }); + expect(s.errorNotifications()).toBe(true); + }); + }); + + describe('and when it is disabled', () => { + it('returns false', () => { + const s = new SettingsData(1, { + settings: [{ name: 'errors', enabled: false }], + }); + expect(s.errorNotifications()).toBe(false); + }); + }); + }); + + describe('when the "errors" setting DOES NOT exist', () => { + it('returns true', () => { + const s = new SettingsData(1, {}); + expect(s.errorNotifications()).toBe(true); + }); + }); + }); + + describe('performanceStats', () => { + describe('when the "apm" setting exists', () => { + describe('and when it is enabled', () => { + it('returns true', () => { + const s = new SettingsData(1, { + settings: [{ name: 'apm', enabled: true }], + }); + expect(s.performanceStats()).toBe(true); + }); + }); + + describe('and when it is disabled', () => { + it('returns false', () => { + const s = new SettingsData(1, { + settings: [{ name: 'apm', enabled: false }], + }); + expect(s.performanceStats()).toBe(false); + }); + }); + }); + + describe('when the "errors" setting DOES NOT exist', () => { + it('returns true', () => { + const s = new SettingsData(1, {}); + expect(s.performanceStats()).toBe(true); + }); + }); + }); + + describe('errorHost', () => { + describe('when the "errors" setting exists', () => { + describe('and when it has an endpoint specified', () => { + it('returns the endpoint', () => { + const s = new SettingsData(1, { + settings: [{ name: 'errors', endpoint: 'http://example.com' }], + }); + expect(s.errorHost()).toMatch('http://example.com'); + }); + }); + + describe('and when it has null endpoint', () => { + it('returns null', () => { + const s = new SettingsData(1, { + settings: [{ name: 'errors', endpoint: null }], + }); + expect(s.errorHost()).toBe(null); + }); + }); + }); + + describe('when the "errors" setting DOES NOT exist', () => { + it('returns null', () => { + const s = new SettingsData(1, {}); + expect(s.errorHost()).toBe(null); + }); + }); + }); + + describe('apmHost', () => { + describe('when the "apm" setting exists', () => { + describe('and when it has an endpoint specified', () => { + it('returns the endpoint', () => { + const s = new SettingsData(1, { + settings: [{ name: 'apm', endpoint: 'http://example.com' }], + }); + expect(s.apmHost()).toMatch('http://example.com'); + }); + }); + + describe('and when it has null endpoint', () => { + it('returns null', () => { + const s = new SettingsData(1, { + settings: [{ name: 'apm', endpoint: null }], + }); + expect(s.apmHost()).toBe(null); + }); + }); + }); + + describe('when the "apm" setting DOES NOT exist', () => { + it('returns null', () => { + const s = new SettingsData(1, {}); + expect(s.apmHost()).toBe(null); + }); + }); + }); +}); diff --git a/packages/node/src/notifier.ts b/packages/node/src/notifier.ts index d98d8f99a..2a4778334 100644 --- a/packages/node/src/notifier.ts +++ b/packages/node/src/notifier.ts @@ -1,4 +1,5 @@ -import { BaseNotifier, INotice, IOptions } from '@airbrake/browser'; +// import { BaseNotifier, INotice, IOptions } from '@airbrake/browser'; +import { BaseNotifier, INotice, IOptions } from '../../browser'; import { nodeFilter } from './filter/node'; import { Scope, ScopeManager } from './scope'; @@ -12,7 +13,6 @@ export class Notifier extends BaseNotifier { if (!opt.environment && process.env.NODE_ENV) { opt.environment = process.env.NODE_ENV; } - opt.performanceStats = opt.performanceStats !== false; super(opt); this.addFilter(nodeFilter); diff --git a/packages/node/tests/notifier.test.js b/packages/node/tests/notifier.test.js index 74f790396..eaad1c075 100644 --- a/packages/node/tests/notifier.test.js +++ b/packages/node/tests/notifier.test.js @@ -7,6 +7,7 @@ describe('Notifier', () => { const notifier = new Notifier({ projectId: 1, projectKey: 'key', + remoteConfig: false, }); expect(notifier._opt.performanceStats).toEqual(true); }); @@ -16,6 +17,7 @@ describe('Notifier', () => { const notifier = new Notifier({ projectId: 1, projectKey: 'key', + remoteConfig: false, }); expect(notifier._instrument.mock.calls.length).toEqual(1); }); @@ -25,6 +27,7 @@ describe('Notifier', () => { projectId: 1, projectKey: 'key', performanceStats: false, + remoteConfig: false, }); expect(notifier._opt.performanceStats).toEqual(false); }); @@ -35,6 +38,7 @@ describe('Notifier', () => { projectId: 1, projectKey: 'key', performanceStats: false, + remoteConfig: false, }); expect(notifier._instrument.mock.calls.length).toEqual(0); }); diff --git a/packages/node/tests/routes.test.js b/packages/node/tests/routes.test.js index 352cce8de..1c43158cc 100644 --- a/packages/node/tests/routes.test.js +++ b/packages/node/tests/routes.test.js @@ -4,6 +4,7 @@ describe('Routes', () => { const opt = { projectId: 1, projectKey: 'test', + remoteConfig: false, }; let notifier; let routes;