diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index 2559c9087219..f59682d90af2 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -58,12 +58,12 @@ export default [ }, Object.assign({}, bundleConfig, { output: Object.assign({}, bundleConfig.output, { - file: 'build/bundle.min.js', + file: 'build/bundle.js', }), }), Object.assign({}, bundleConfig, { output: Object.assign({}, bundleConfig.output, { - file: 'build/bundle.js', + file: 'build/bundle.min.js', }), // Uglify has to be at the end of compilation, BUT before the license banner plugins: bundleConfig.plugins diff --git a/packages/browser/src/backend.ts b/packages/browser/src/backend.ts index d08df5bfff46..ebc270a14f04 100644 --- a/packages/browser/src/backend.ts +++ b/packages/browser/src/backend.ts @@ -1,10 +1,10 @@ import { Backend, logger, Options, SentryError } from '@sentry/core'; -import { SentryEvent, SentryResponse, Status } from '@sentry/types'; +import { SentryEvent, SentryEventHint, SentryResponse, Severity, Status, Transport } from '@sentry/types'; import { isDOMError, isDOMException, isError, isErrorEvent, isPlainObject } from '@sentry/utils/is'; -import { supportsFetch } from '@sentry/utils/supports'; +import { supportsBeacon, supportsFetch } from '@sentry/utils/supports'; import { eventFromPlainObject, eventFromStacktrace, prepareFramesForEvent } from './parsers'; import { computeStackTrace } from './tracekit'; -import { FetchTransport, XHRTransport } from './transports'; +import { BeaconTransport, FetchTransport, XHRTransport } from './transports'; /** * Configuration options for the Sentry Browser SDK. @@ -37,6 +37,9 @@ export class BrowserBackend implements Backend { /** Creates a new browser backend instance. */ public constructor(private readonly options: BrowserOptions = {}) {} + /** Cached transport used internally. */ + private transport?: Transport; + /** * @inheritDoc */ @@ -57,7 +60,7 @@ export class BrowserBackend implements Backend { /** * @inheritDoc */ - public async eventFromException(exception: any, syntheticException: Error | null): Promise { + public async eventFromException(exception: any, hint?: SentryEventHint): Promise { let event; if (isErrorEvent(exception as ErrorEvent) && (exception as ErrorEvent).error) { @@ -74,16 +77,16 @@ export class BrowserBackend implements Backend { const name = ex.name || (isDOMError(ex) ? 'DOMError' : 'DOMException'); const message = ex.message ? `${name}: ${ex.message}` : name; - event = await this.eventFromMessage(message, syntheticException); + event = await this.eventFromMessage(message, undefined, hint); } else if (isError(exception as Error)) { // we have a real Error object, do nothing event = eventFromStacktrace(computeStackTrace(exception as Error)); - } else if (isPlainObject(exception as {})) { + } else if (isPlainObject(exception as {}) && hint && hint.syntheticException) { // If it is plain Object, serialize it manually and extract options // This will allow us to group events based on top-level keys // which is much better than creating new group when any key/value change const ex = exception as {}; - event = eventFromPlainObject(ex, syntheticException); + event = eventFromPlainObject(ex, hint.syntheticException); } else { // If none of previous checks were valid, then it means that // it's not a DOMError/DOMException @@ -92,11 +95,12 @@ export class BrowserBackend implements Backend { // it's not an Error // So bail out and capture it as a simple message: const ex = exception as string; - event = await this.eventFromMessage(ex, syntheticException); + event = await this.eventFromMessage(ex, undefined, hint); } event = { ...event, + event_id: hint && hint.event_id, exception: { ...event.exception, mechanism: { @@ -112,14 +116,16 @@ export class BrowserBackend implements Backend { /** * @inheritDoc */ - public async eventFromMessage(message: string, syntheticException: Error | null): Promise { + public async eventFromMessage(message: string, level?: Severity, hint?: SentryEventHint): Promise { const event: SentryEvent = { + event_id: hint && hint.event_id, fingerprint: [message], + level, message, }; - if (this.options.attachStacktrace && syntheticException) { - const stacktrace = computeStackTrace(syntheticException); + if (this.options.attachStacktrace && hint && hint.syntheticException) { + const stacktrace = computeStackTrace(hint.syntheticException); const frames = prepareFramesForEvent(stacktrace.stack); event.stacktrace = { frames, @@ -139,15 +145,23 @@ export class BrowserBackend implements Backend { return { status: Status.Skipped }; } - const transportOptions = this.options.transportOptions ? this.options.transportOptions : { dsn: this.options.dsn }; - - const transport = this.options.transport - ? new this.options.transport({ dsn: this.options.dsn }) - : supportsFetch() - ? new FetchTransport(transportOptions) - : new XHRTransport(transportOptions); + if (!this.transport) { + const transportOptions = this.options.transportOptions + ? this.options.transportOptions + : { dsn: this.options.dsn }; + + if (this.options.transport) { + this.transport = new this.options.transport({ dsn: this.options.dsn }); + } else if (supportsBeacon()) { + this.transport = new BeaconTransport(transportOptions); + } else if (supportsFetch()) { + this.transport = new FetchTransport(transportOptions); + } else { + this.transport = new XHRTransport(transportOptions); + } + } - return transport.send(event); + return this.transport.send(event); } /** diff --git a/packages/browser/src/integrations/dedupe.ts b/packages/browser/src/integrations/dedupe.ts index 25ffd1d9190f..fb6f9a51814d 100644 --- a/packages/browser/src/integrations/dedupe.ts +++ b/packages/browser/src/integrations/dedupe.ts @@ -1,5 +1,5 @@ import { logger } from '@sentry/core'; -import { getCurrentHub } from '@sentry/hub'; +import { getCurrentHub, Scope } from '@sentry/hub'; import { Integration, SentryEvent, SentryException, StackFrame } from '@sentry/types'; /** Deduplication filter */ @@ -18,17 +18,19 @@ export class Dedupe implements Integration { * @inheritDoc */ public install(): void { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => { - // Juuust in case something goes wrong - try { - if (this.shouldDropEvent(event)) { - return null; + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => { + // Juuust in case something goes wrong + try { + if (this.shouldDropEvent(event)) { + return null; + } + } catch (_oO) { + return (this.previousEvent = event); } - } catch (_oO) { - return (this.previousEvent = event); - } - return (this.previousEvent = event); + return (this.previousEvent = event); + }); }); } diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index 5048fa424347..8f152090fa08 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -1,5 +1,5 @@ import { logger } from '@sentry/core'; -import { captureEvent } from '@sentry/minimal'; +import { getCurrentHub } from '@sentry/hub'; import { Integration, SentryEvent } from '@sentry/types'; import { eventFromStacktrace } from '../parsers'; import { @@ -29,7 +29,7 @@ export class GlobalHandlers implements Integration { * @inheritDoc */ public install(): void { - subscribe((stack: TraceKitStackTrace) => { + subscribe((stack: TraceKitStackTrace, _: boolean, error: Error) => { // TODO: use stack.context to get a valuable information from TraceKit, eg. // [ // 0: " })" @@ -47,7 +47,7 @@ export class GlobalHandlers implements Integration { if (shouldIgnoreOnError()) { return; } - captureEvent(this.eventFromGlobalHandler(stack)); + getCurrentHub().captureEvent(this.eventFromGlobalHandler(stack), { originalException: error, data: { stack } }); }); if (this.options.onerror) { diff --git a/packages/browser/src/integrations/helpers.ts b/packages/browser/src/integrations/helpers.ts index 810c2bf9c013..2617461c301b 100644 --- a/packages/browser/src/integrations/helpers.ts +++ b/packages/browser/src/integrations/helpers.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/hub'; +import { getCurrentHub, Scope } from '@sentry/hub'; import { Mechanism, SentryEvent, SentryWrappedFunction } from '@sentry/types'; import { isFunction } from '@sentry/utils/is'; import { htmlTreeAsString } from '@sentry/utils/misc'; @@ -66,18 +66,20 @@ export function wrap( ignoreNextOnError(); getCurrentHub().withScope(async () => { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => { - const processedEvent = { ...event }; + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => { + const processedEvent = { ...event }; - if (options.mechanism) { - processedEvent.exception = processedEvent.exception || {}; - processedEvent.exception.mechanism = options.mechanism; - } + if (options.mechanism) { + processedEvent.exception = processedEvent.exception || {}; + processedEvent.exception.mechanism = options.mechanism; + } - return processedEvent; + return processedEvent; + }); }); - getCurrentHub().captureException(ex); + getCurrentHub().captureException(ex, { originalException: ex }); }); throw ex; diff --git a/packages/browser/src/integrations/inboundfilters.ts b/packages/browser/src/integrations/inboundfilters.ts index 419621082417..ffecff803bd2 100644 --- a/packages/browser/src/integrations/inboundfilters.ts +++ b/packages/browser/src/integrations/inboundfilters.ts @@ -1,5 +1,5 @@ import { logger } from '@sentry/core'; -import { getCurrentHub } from '@sentry/hub'; +import { getCurrentHub, Scope } from '@sentry/hub'; import { Integration, SentryEvent } from '@sentry/types'; import { isRegExp } from '@sentry/utils/is'; import { BrowserOptions } from '../backend'; @@ -27,11 +27,13 @@ export class InboundFilters implements Integration { public install(options: BrowserOptions = {}): void { this.configureOptions(options); - getCurrentHub().addEventProcessor(async (event: SentryEvent) => { - if (this.shouldDropEvent(event)) { - return null; - } - return event; + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => { + if (this.shouldDropEvent(event)) { + return null; + } + return event; + }); }); } diff --git a/packages/browser/src/integrations/sdkinformation.ts b/packages/browser/src/integrations/sdkinformation.ts index f7bf81661027..e731d548dd5f 100644 --- a/packages/browser/src/integrations/sdkinformation.ts +++ b/packages/browser/src/integrations/sdkinformation.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/hub'; +import { getCurrentHub, Scope } from '@sentry/hub'; import { Integration, SentryEvent } from '@sentry/types'; import { SDK_NAME, SDK_VERSION } from '../version'; @@ -13,19 +13,21 @@ export class SDKInformation implements Integration { * @inheritDoc */ public install(): void { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => ({ - ...event, - sdk: { - name: SDK_NAME, - packages: [ - ...((event.sdk && event.sdk.packages) || []), - { - name: 'npm:@sentry/browser', - version: SDK_VERSION, - }, - ], - version: SDK_VERSION, - }, - })); + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => ({ + ...event, + sdk: { + name: SDK_NAME, + packages: [ + ...((event.sdk && event.sdk.packages) || []), + { + name: 'npm:@sentry/browser', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }, + })); + }); } } diff --git a/packages/browser/src/tracekit/index.js b/packages/browser/src/tracekit/index.js index 8f884cc7daa7..377afc57d8f3 100644 --- a/packages/browser/src/tracekit/index.js +++ b/packages/browser/src/tracekit/index.js @@ -230,7 +230,6 @@ TraceKit.report = (function reportModuleWrapper() { * @memberof TraceKit.report */ function traceKitWindowOnError(message, url, lineNo, columnNo, errorObj) { - debugger; var stack = null; // If 'errorObj' is ErrorEvent, get real Error from inside errorObj = isErrorEvent(errorObj) ? errorObj.error : errorObj; diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index b95f230fe7c7..1a3e358c7551 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -14,7 +14,6 @@ export class FetchTransport extends BaseTransport { public async send(event: SentryEvent): Promise { const defaultOptions: RequestInit = { body: serialize(event), - keepalive: true, method: 'POST', // Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default // https://caniuse.com/#feat=referrer-policy diff --git a/packages/browser/test/index.test.ts b/packages/browser/test/index.test.ts index eec771a1ed77..7c9ae1f80e30 100644 --- a/packages/browser/test/index.test.ts +++ b/packages/browser/test/index.test.ts @@ -12,6 +12,7 @@ import { init, Scope, SentryEvent, + Status, } from '../src'; const dsn = 'https://53039209a22b4ec1bcc296a3c9fdecd6@sentry.io/4291'; @@ -64,7 +65,7 @@ describe('SentryBrowser', () => { let s: sinon.SinonStub; beforeEach(() => { - s = stub(BrowserBackend.prototype, 'sendEvent').returns(Promise.resolve(200)); + s = stub(BrowserBackend.prototype, 'sendEvent').returns(Promise.resolve({ status: Status.Success })); }); afterEach(() => { @@ -75,9 +76,10 @@ describe('SentryBrowser', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new BrowserClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.breadcrumbs!).to.have.lengthOf(2); done(); + return event; }, dsn, }), @@ -95,7 +97,7 @@ describe('SentryBrowser', () => { let s: sinon.SinonStub; beforeEach(() => { - s = stub(BrowserBackend.prototype, 'sendEvent').returns(Promise.resolve(200)); + s = stub(BrowserBackend.prototype, 'sendEvent').returns(Promise.resolve({ status: Status.Success })); }); afterEach(() => { @@ -106,13 +108,14 @@ describe('SentryBrowser', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new BrowserClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.exception).to.not.be.undefined; expect(event.exception!.values![0]).to.not.be.undefined; expect(event.exception!.values![0].type).to.equal('Error'); expect(event.exception!.values![0].value).to.equal('test'); expect(event.exception!.values![0].stacktrace).to.not.be.empty; done(); + return event; }, dsn, }), @@ -129,10 +132,11 @@ describe('SentryBrowser', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new BrowserClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.message).to.equal('test'); expect(event.exception).to.be.undefined; done(); + return event; }, dsn, }), @@ -145,10 +149,11 @@ describe('SentryBrowser', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new BrowserClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.message).to.equal('event'); expect(event.exception).to.be.undefined; done(); + return event; }, dsn, }), diff --git a/packages/browser/test/integration/frame.html b/packages/browser/test/integration/frame.html index 8dab2ef71339..a3f543f2c705 100644 --- a/packages/browser/test/integration/frame.html +++ b/packages/browser/test/integration/frame.html @@ -113,14 +113,16 @@ DummyTransport.prototype.send = function (event) { // console.log(JSON.stringify(event, null, 2)); sentryData.push(event); - return true; + return { + status: 'success' + } } Sentry.init({ dsn: 'https://public@example.com/1', attachStacktrace: true, transport: DummyTransport, - shouldAddBreadcrumb: function (breadcrumb) { + beforeBreadcrumb: function (breadcrumb) { // Filter internal Karma requests if ( breadcrumb.type === 'http' && @@ -129,9 +131,9 @@ breadcrumb.data.url.indexOf('frame.html') !== -1 ) ) { - return false; + return null; } - return true; + return breadcrumb; } }) diff --git a/packages/browser/test/integration/test.js b/packages/browser/test/integration/test.js index 1fe5c47973c7..0f1d20cc7696 100644 --- a/packages/browser/test/integration/test.js +++ b/packages/browser/test/integration/test.js @@ -1126,7 +1126,6 @@ describe('integration', function() { function() { var Sentry = iframe.contentWindow.Sentry; var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; - assert.equal(breadcrumbs.length, 1); assert.equal(breadcrumbs[0].category, 'ui.input'); assert.equal(breadcrumbs[0].message, 'body > form#foo-form > input[name="foo"]'); @@ -1162,7 +1161,6 @@ describe('integration', function() { var Sentry = iframe.contentWindow.Sentry; var breadcrumbs = Sentry.getCurrentHub().getScope().breadcrumbs; - // 2x `ui_event` assert.equal(breadcrumbs.length, 3); assert.equal(breadcrumbs[0].category, 'ui.input'); diff --git a/packages/browser/test/transports/fetch.test.ts b/packages/browser/test/transports/fetch.test.ts index 00db2f5bdd50..b57b85ae3bd0 100644 --- a/packages/browser/test/transports/fetch.test.ts +++ b/packages/browser/test/transports/fetch.test.ts @@ -41,7 +41,6 @@ describe('FetchTransport', () => { expect( fetch.calledWith(transportUrl, { body: JSON.stringify(payload), - keepalive: true, method: 'POST', referrerPolicy: 'origin', }), @@ -60,7 +59,6 @@ describe('FetchTransport', () => { expect( fetch.calledWith(transportUrl, { body: JSON.stringify(payload), - keepalive: true, method: 'POST', referrerPolicy: 'origin', }), diff --git a/packages/core/src/base.ts b/packages/core/src/base.ts index 3890ce723450..233b82f176ef 100644 --- a/packages/core/src/base.ts +++ b/packages/core/src/base.ts @@ -1,5 +1,5 @@ import { Scope } from '@sentry/hub'; -import { Breadcrumb, SentryEvent, SentryResponse, Status } from '@sentry/types'; +import { Breadcrumb, SentryEvent, SentryEventHint, SentryResponse, Severity, Status } from '@sentry/types'; import { uuid4 } from '@sentry/utils/misc'; import { truncate } from '@sentry/utils/string'; import { DSN } from './dsn'; @@ -116,36 +116,36 @@ export abstract class BaseClient implement /** * @inheritDoc */ - public async captureException(exception: any, syntheticException: Error | null, scope?: Scope): Promise { - const event = await this.getBackend().eventFromException(exception, syntheticException); - await this.captureEvent(event, scope); + public async captureException(exception: any, hint?: SentryEventHint, scope?: Scope): Promise { + const event = await this.getBackend().eventFromException(exception, hint); + return this.captureEvent(event, hint, scope); } /** * @inheritDoc */ - public async captureMessage(message: string, syntheticException: Error | null, scope?: Scope): Promise { - const event = await this.getBackend().eventFromMessage(message, syntheticException); - await this.captureEvent(event, scope); + public async captureMessage( + message: string, + level?: Severity, + hint?: SentryEventHint, + scope?: Scope, + ): Promise { + const event = await this.getBackend().eventFromMessage(message, level, hint); + return this.captureEvent(event, hint, scope); } /** * @inheritDoc */ - public async captureEvent(event: SentryEvent, scope?: Scope): Promise { - return this.processEvent(event, async finalEvent => this.getBackend().sendEvent(finalEvent), scope); + public async captureEvent(event: SentryEvent, hint?: SentryEventHint, scope?: Scope): Promise { + return this.processEvent(event, async finalEvent => this.getBackend().sendEvent(finalEvent), hint, scope); } /** * @inheritDoc */ public async addBreadcrumb(breadcrumb: Breadcrumb, scope?: Scope): Promise { - const { - shouldAddBreadcrumb, - beforeBreadcrumb, - afterBreadcrumb, - maxBreadcrumbs = DEFAULT_BREADCRUMBS, - } = this.getOptions(); + const { beforeBreadcrumb, maxBreadcrumbs = DEFAULT_BREADCRUMBS } = this.getOptions(); if (maxBreadcrumbs <= 0) { return; @@ -153,19 +153,15 @@ export abstract class BaseClient implement const timestamp = new Date().getTime() / 1000; const mergedBreadcrumb = { timestamp, ...breadcrumb }; - if (shouldAddBreadcrumb && !shouldAddBreadcrumb(mergedBreadcrumb)) { + const finalBreadcrumb = beforeBreadcrumb ? beforeBreadcrumb(mergedBreadcrumb) : mergedBreadcrumb; + + if (finalBreadcrumb === null) { return; } - const finalBreadcrumb = beforeBreadcrumb ? beforeBreadcrumb(mergedBreadcrumb) : mergedBreadcrumb; - if ((await this.getBackend().storeBreadcrumb(finalBreadcrumb)) && scope) { scope.addBreadcrumb(finalBreadcrumb, Math.min(maxBreadcrumbs, MAX_BREADCRUMBS)); } - - if (afterBreadcrumb) { - afterBreadcrumb(finalBreadcrumb); - } } /** @@ -202,10 +198,11 @@ export abstract class BaseClient implement * nested objects, such as the context, keys are merged. * * @param event The original event. + * @param hint May contain additional informartion about the original exception. * @param scope A scope containing event metadata. * @returns A new event with more information. */ - protected async prepareEvent(event: SentryEvent, scope?: Scope): Promise { + protected async prepareEvent(event: SentryEvent, scope?: Scope, hint?: SentryEventHint): Promise { const { environment, maxBreadcrumbs = DEFAULT_BREADCRUMBS, release, repos, dist } = this.getOptions(); const prepared = { ...event }; @@ -238,12 +235,14 @@ export abstract class BaseClient implement request.url = truncate(request.url, MAX_URL_LENGTH); } - prepared.event_id = uuid4(); + if (prepared.event_id === undefined) { + prepared.event_id = uuid4(); + } // This should be the last thing called, since we want that // {@link Hub.addEventProcessor} gets the finished prepared event. if (scope) { - return scope.applyToEvent(prepared, Math.min(maxBreadcrumbs, MAX_BREADCRUMBS)); + return scope.applyToEvent(prepared, hint, Math.min(maxBreadcrumbs, MAX_BREADCRUMBS)); } return prepared; @@ -264,11 +263,13 @@ export abstract class BaseClient implement * @param event The event to send to Sentry. * @param send A function to actually send the event. * @param scope A scope containing event metadata. + * @param hint May contain additional informartion about the original exception. * @returns A Promise that resolves with the event status. */ protected async processEvent( event: SentryEvent, send: (finalEvent: SentryEvent) => Promise, + hint?: SentryEventHint, scope?: Scope, ): Promise { if (!this.isEnabled()) { @@ -277,7 +278,7 @@ export abstract class BaseClient implement }; } - const { shouldSend, beforeSend, afterSend, sampleRate } = this.getOptions(); + const { beforeSend, sampleRate } = this.getOptions(); if (typeof sampleRate === 'number' && sampleRate > Math.random()) { return { @@ -285,26 +286,28 @@ export abstract class BaseClient implement }; } - const prepared = await this.prepareEvent(event, scope); - if (prepared === null || (shouldSend && !shouldSend(prepared))) { + const prepared = await this.prepareEvent(event, scope, hint); + if (prepared === null) { + return { + status: Status.Skipped, + }; + } + + const finalEvent = beforeSend ? beforeSend(prepared, hint) : prepared; + if (finalEvent === null) { return { status: Status.Skipped, }; } - const finalEvent = beforeSend ? beforeSend(prepared) : prepared; const response = await send(finalEvent); + response.event = finalEvent; if (response.status === Status.RateLimit) { // TODO: Handle rate limits and maintain a queue. For now, we require SDK // implementors to override this method and handle it themselves. } - // TODO: Handle duplicates and backoffs - if (afterSend) { - afterSend(finalEvent, response); - } - return response; } } diff --git a/packages/core/src/interfaces.ts b/packages/core/src/interfaces.ts index 6ca45933eb18..635371579409 100644 --- a/packages/core/src/interfaces.ts +++ b/packages/core/src/interfaces.ts @@ -4,7 +4,9 @@ import { Integration, Repo, SentryEvent, + SentryEventHint, SentryResponse, + Severity, Transport, TransportClass, TransportOptions, @@ -88,76 +90,32 @@ export interface Options { /** Attaches stacktraces to pure capture message / log integrations */ attachStacktrace?: boolean; - /** - * A callback invoked during event submission, allowing to cancel the process. - * If unspecified, all events will be sent to Sentry. - * - * This function is called for both error and message events before all other - * callbacks. Note that the SDK might perform other actions after calling this - * function. Use {@link Options.beforeSend} for notifications on events - * instead. - * - * @param event The error or message event generated by the SDK. - * @returns True if the event should be sent, false otherwise. - */ - shouldSend?(event: SentryEvent): boolean; - /** * A callback invoked during event submission, allowing to optionally modify * the event before it is sent to Sentry. * - * This function is called after {@link Options.shouldSend} and just before - * sending the event and must return synchronously. - * * Note that you must return a valid event from this callback. If you do not - * wish to modify the event, simply return it at the end. To cancel event - * submission instead, use {@link Options.shouldSend}. + * wish to modify the event, simply return it at the end. + * Returning null will case the event to be dropped. * * @param event The error or message event generated by the SDK. - * @returns A new event that will be sent. + * @param hint May contain additional informartion about the original exception. + * @returns A new event that will be sent | null. */ - beforeSend?(event: SentryEvent): SentryEvent; - - /** - * A callback invoked after event submission has completed. - * @param event The error or message event sent to Sentry. - */ - afterSend?(event: SentryEvent, status: SentryResponse): void; - - /** - * A callback allowing to skip breadcrumbs. - * - * This function is called for both manual and automatic breadcrumbs before - * all other callbacks. Note that the SDK might perform other actions after - * calling this function. Use {@link Options.beforeBreadcrumb} for - * notifications on breadcrumbs instead. - * - * @param breadcrumb The breadcrumb as created by the SDK. - * @returns True if the breadcrumb should be added, false otherwise. - */ - shouldAddBreadcrumb?(breadcrumb: Breadcrumb): boolean; + beforeSend?(event: SentryEvent, hint?: SentryEventHint): SentryEvent | null; /** * A callback invoked when adding a breadcrumb, allowing to optionally modify * it before adding it to future events. * - * This function is called after {@link Options.shouldAddBreadcrumb} and just - * before persisting the breadcrumb. It must return synchronously. - * * Note that you must return a valid breadcrumb from this callback. If you do - * not wish to modify the breadcrumb, simply return it at the end. To skip a - * breadcrumb instead, use {@link Options.shouldAddBreadcrumb}. + * not wish to modify the breadcrumb, simply return it at the end. + * Returning null will case the breadcrumb to be dropped. * * @param breadcrumb The breadcrumb as created by the SDK. - * @returns The breadcrumb that will be added. - */ - beforeBreadcrumb?(breadcrumb: Breadcrumb): Breadcrumb; - - /** - * A callback invoked after adding a breadcrumb. - * @param breadcrumb The breadcrumb as created by the SDK. + * @returns The breadcrumb that will be added | null. */ - afterBreadcrumb?(breadcrumb: Breadcrumb): void; + beforeBreadcrumb?(breadcrumb: Breadcrumb): Breadcrumb | null; } /** @@ -192,30 +150,32 @@ export interface Client { * Captures an exception event and sends it to Sentry. * * @param exception An exception-like object. + * @param hint May contain additional informartion about the original exception. * @param scope An optional scope containing event metadata. - * @param syntheticException Manually thrown exception at the very top, to get _any_ valuable stack trace - * @returns The created event id. + * @returns SentryResponse status and event */ - captureException(exception: any, syntheticException: Error | null, scope?: Scope): Promise; + captureException(exception: any, hint?: SentryEventHint, scope?: Scope): Promise; /** * Captures a message event and sends it to Sentry. * * @param message The message to send to Sentry. + * @param level Define the level of the message. + * @param hint May contain additional informartion about the original exception. * @param scope An optional scope containing event metadata. - * @param syntheticException Manually thrown exception at the very top, to get _any_ valuable stack trace - * @returns The created event id. + * @returns SentryResponse status and event */ - captureMessage(message: string, syntheticException: Error | null, scope?: Scope): Promise; + captureMessage(message: string, level?: Severity, hint?: SentryEventHint, scope?: Scope): Promise; /** * Captures a manually created event and sends it to Sentry. * * @param event The event to send to Sentry. + * @param hint May contain additional informartion about the original exception. * @param scope An optional scope containing event metadata. - * @returns The created event id. + * @returns SentryResponse status and event */ - captureEvent(event: SentryEvent, scope?: Scope): Promise; + captureEvent(event: SentryEvent, hint?: SentryEventHint, scope?: Scope): Promise; /** * Records a new breadcrumb which will be attached to future events. @@ -262,10 +222,10 @@ export interface Backend { install?(): boolean; /** Creates a {@link SentryEvent} from an exception. */ - eventFromException(exception: any, syntheticException: Error | null): Promise; + eventFromException(exception: any, hint?: SentryEventHint): Promise; /** Creates a {@link SentryEvent} from a plain message. */ - eventFromMessage(message: string, syntheticException: Error | null): Promise; + eventFromMessage(message: string, level?: Severity, hint?: SentryEventHint): Promise; /** Submits the event to Sentry */ sendEvent(event: SentryEvent): Promise; diff --git a/packages/core/test/lib/base.test.ts b/packages/core/test/lib/base.test.ts index 23d565877171..2accf1a05e43 100644 --- a/packages/core/test/lib/base.test.ts +++ b/packages/core/test/lib/base.test.ts @@ -1,8 +1,8 @@ import { Scope } from '@sentry/hub'; -import { Breadcrumb, SentryEvent } from '@sentry/types'; +import { Status } from '@sentry/types'; import { SentryError } from '../../src/error'; import { TestBackend } from '../mocks/backend'; -import { TEST_SDK, TestClient } from '../mocks/client'; +import { TestClient } from '../mocks/client'; const PUBLIC_DSN = 'https://username@domain/path'; @@ -105,31 +105,22 @@ describe('BaseClient', () => { expect(scope.getBreadcrumbs()[0].message).toBe('world'); }); - test('exits early when breadcrumbs are deactivated', async () => { - const shouldAddBreadcrumb = jest.fn(); - const client = new TestClient({ - maxBreadcrumbs: 0, - shouldAddBreadcrumb, - }); - const scope = new Scope(); - await client.addBreadcrumb({ message: 'hello' }, scope); - expect(shouldAddBreadcrumb.mock.calls).toHaveLength(0); - }); - - test('calls shouldAddBreadcrumb and adds the breadcrumb', async () => { - const shouldAddBreadcrumb = jest.fn(() => true); - const client = new TestClient({ shouldAddBreadcrumb }); + test('allows concurrent updates', async () => { + const client = new TestClient({}); const scope = new Scope(); - await client.addBreadcrumb({ message: 'hello' }, scope); - expect(scope.getBreadcrumbs().length).toBe(1); + await Promise.all([ + client.addBreadcrumb({ message: 'hello' }, scope), + client.addBreadcrumb({ message: 'world' }, scope), + ]); + expect(scope.getBreadcrumbs()).toHaveLength(2); }); - test('calls shouldAddBreadcrumb and discards the breadcrumb', async () => { - const shouldAddBreadcrumb = jest.fn(() => false); - const client = new TestClient({ shouldAddBreadcrumb }); + test('calls beforeBreadcrumb and adds the breadcrumb without any changes', async () => { + const beforeBreadcrumb = jest.fn(breadcrumb => breadcrumb); + const client = new TestClient({ beforeBreadcrumb }); const scope = new Scope(); await client.addBreadcrumb({ message: 'hello' }, scope); - expect(scope.getBreadcrumbs().length).toBe(0); + expect(scope.getBreadcrumbs()[0].message).toBe('hello'); }); test('calls beforeBreadcrumb and uses the new one', async () => { @@ -140,23 +131,12 @@ describe('BaseClient', () => { expect(scope.getBreadcrumbs()[0].message).toBe('changed'); }); - test('calls afterBreadcrumb', async () => { - const afterBreadcrumb = jest.fn(); - const client = new TestClient({ afterBreadcrumb }); + test('calls shouldAddBreadcrumb and discards the breadcrumb', async () => { + const beforeBreadcrumb = jest.fn(() => null); + const client = new TestClient({ beforeBreadcrumb }); const scope = new Scope(); await client.addBreadcrumb({ message: 'hello' }, scope); - const breadcrumb = afterBreadcrumb.mock.calls[0][0] as Breadcrumb; - expect(breadcrumb.message).toBe('hello'); - }); - - test('allows concurrent updates', async () => { - const client = new TestClient({}); - const scope = new Scope(); - await Promise.all([ - client.addBreadcrumb({ message: 'hello' }, scope), - client.addBreadcrumb({ message: 'world' }, scope), - ]); - expect(scope.getBreadcrumbs()).toHaveLength(2); + expect(scope.getBreadcrumbs().length).toBe(0); }); }); @@ -164,7 +144,7 @@ describe('BaseClient', () => { test('captures and sends exceptions', async () => { const client = new TestClient({ dsn: PUBLIC_DSN }); const scope = new Scope(); - await client.captureException(new Error('test exception'), scope); + await client.captureException(new Error('test exception'), undefined, scope); expect(TestBackend.instance!.event).toEqual({ event_id: '42', exception: { @@ -176,18 +156,16 @@ describe('BaseClient', () => { ], }, message: 'Error: test exception', - sdk: TEST_SDK, }); }); test('captures and sends messages', async () => { const client = new TestClient({ dsn: PUBLIC_DSN }); const scope = new Scope(); - await client.captureMessage('test message', scope); + await client.captureMessage('test message', undefined, undefined, scope); expect(TestBackend.instance!.event).toEqual({ event_id: '42', message: 'test message', - sdk: TEST_SDK, }); }); }); @@ -196,26 +174,25 @@ describe('BaseClient', () => { test('skips when disabled', async () => { const client = new TestClient({ enabled: false, dsn: PUBLIC_DSN }); const scope = new Scope(); - await client.captureEvent({}, scope); + await client.captureEvent({}, undefined, scope); expect(TestBackend.instance!.event).toBeUndefined(); }); test('skips without a DSN', async () => { const client = new TestClient({}); const scope = new Scope(); - await client.captureEvent({}, scope); + await client.captureEvent({}, undefined, scope); expect(TestBackend.instance!.event).toBeUndefined(); }); test('sends an event', async () => { const client = new TestClient({ dsn: PUBLIC_DSN }); const scope = new Scope(); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!.message).toBe('message'); expect(TestBackend.instance!.event).toEqual({ event_id: '42', message: 'message', - sdk: TEST_SDK, }); }); @@ -225,12 +202,11 @@ describe('BaseClient', () => { environment: 'env', }); const scope = new Scope(); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!).toEqual({ environment: 'env', event_id: '42', message: 'message', - sdk: TEST_SDK, }); }); @@ -240,12 +216,11 @@ describe('BaseClient', () => { release: 'v1.0.0', }); const scope = new Scope(); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!).toEqual({ event_id: '42', message: 'message', release: 'v1.0.0', - sdk: TEST_SDK, }); }); @@ -253,12 +228,11 @@ describe('BaseClient', () => { const client = new TestClient({ dsn: PUBLIC_DSN }); const scope = new Scope(); scope.addBreadcrumb({ message: 'breadcrumb' }, 100); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!).toEqual({ breadcrumbs: [{ message: 'breadcrumb' }], event_id: '42', message: 'message', - sdk: TEST_SDK, }); }); @@ -267,12 +241,11 @@ describe('BaseClient', () => { const scope = new Scope(); scope.addBreadcrumb({ message: '1' }, 100); scope.addBreadcrumb({ message: '2' }, 200); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!).toEqual({ breadcrumbs: [{ message: '2' }], event_id: '42', message: 'message', - sdk: TEST_SDK, }); }); @@ -282,12 +255,11 @@ describe('BaseClient', () => { scope.setExtra('b', 'b'); scope.setTag('a', 'a'); scope.setUser({ id: 'user' }); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!).toEqual({ event_id: '42', extra: { b: 'b' }, message: 'message', - sdk: TEST_SDK, tags: { a: 'a' }, user: { id: 'user' }, }); @@ -297,32 +269,19 @@ describe('BaseClient', () => { const client = new TestClient({ dsn: PUBLIC_DSN }); const scope = new Scope(); scope.setFingerprint(['abcd']); - await client.captureEvent({ message: 'message' }, scope); + await client.captureEvent({ message: 'message' }, undefined, scope); expect(TestBackend.instance!.event!).toEqual({ event_id: '42', fingerprint: ['abcd'], message: 'message', - sdk: TEST_SDK, }); }); - test('calls shouldSend and adds the event', async () => { - const shouldSend = jest.fn(() => true); - const client = new TestClient({ dsn: PUBLIC_DSN, shouldSend }); - const scope = new Scope(); - await client.captureEvent({ message: 'hello' }, scope); - expect(TestBackend.instance!.event).toEqual({ - event_id: '42', - message: 'hello', - sdk: TEST_SDK, - }); - }); - - test('calls shouldSend and discards the event', async () => { - const shouldSend = jest.fn(() => false); - const client = new TestClient({ dsn: PUBLIC_DSN, shouldSend }); + test('calls beforeSend and discards the event', async () => { + const beforeSend = jest.fn(() => null); + const client = new TestClient({ dsn: PUBLIC_DSN, beforeSend }); const scope = new Scope(); - await client.captureEvent({ message: 'hello' }, scope); + await client.captureEvent({ message: 'hello' }, undefined, scope); expect(TestBackend.instance!.event).toBeUndefined(); }); @@ -330,24 +289,15 @@ describe('BaseClient', () => { const beforeSend = jest.fn(() => ({ message: 'changed' })); const client = new TestClient({ dsn: PUBLIC_DSN, beforeSend }); const scope = new Scope(); - await client.captureEvent({ message: 'hello' }, scope); + await client.captureEvent({ message: 'hello' }, undefined, scope); expect(TestBackend.instance!.event!.message).toBe('changed'); }); - test('calls afterSend', async () => { - const afterSend = jest.fn(); - const client = new TestClient({ dsn: PUBLIC_DSN, afterSend }); - const scope = new Scope(); - await client.captureEvent({ message: 'hello' }, scope); - const breadcrumb = afterSend.mock.calls[0][0] as SentryEvent; - expect(breadcrumb.message).toBe('hello'); - }); - it("doesn't do anything with rate limits yet", async () => { const client = new TestClient({ dsn: PUBLIC_DSN }); - TestBackend.instance!.sendEvent = async () => 429; + TestBackend.instance!.sendEvent = async () => ({ status: Status.RateLimit }); const scope = new Scope(); - await client.captureEvent({}, scope); + await client.captureEvent({}, undefined, scope); // TODO: Test rate limiting queues here }); }); diff --git a/packages/core/test/mocks/backend.ts b/packages/core/test/mocks/backend.ts index 000fc1701dc2..84c335d54dec 100644 --- a/packages/core/test/mocks/backend.ts +++ b/packages/core/test/mocks/backend.ts @@ -1,5 +1,5 @@ import { Scope } from '@sentry/hub'; -import { Breadcrumb, SentryEvent, SentryResponse } from '@sentry/types'; +import { Breadcrumb, SentryEvent, SentryResponse, Status } from '@sentry/types'; import { Backend, Options } from '../../src/interfaces'; export interface TestOptions extends Options { @@ -43,7 +43,7 @@ export class TestBackend implements Backend { public async sendEvent(event: SentryEvent): Promise { this.event = event; - return { code: 200 }; + return { status: Status.Success }; } public storeBreadcrumb(_breadcrumb: Breadcrumb): boolean | Promise { diff --git a/packages/hub/src/hub.ts b/packages/hub/src/hub.ts index 273f7f9fd52d..793fe2cb6329 100644 --- a/packages/hub/src/hub.ts +++ b/packages/hub/src/hub.ts @@ -1,4 +1,5 @@ -import { Breadcrumb, SentryEvent } from '@sentry/types'; +import { Breadcrumb, SentryEvent, SentryEventHint, Severity } from '@sentry/types'; +import { uuid4 } from '@sentry/utils/misc'; import { Layer } from './interfaces'; import { Scope } from './scope'; @@ -8,7 +9,7 @@ import { Scope } from './scope'; * WARNING: This number should only be incresed when the global interface * changes a and new methods are introduced. */ -export const API_VERSION = 2; +export const API_VERSION = 3; /** * Internal class used to make sure we always have the latest internal functions @@ -18,6 +19,9 @@ export class Hub { /** Is a {@link Layer}[] containing the client and scope */ private readonly stack: Layer[] = []; + /** Contains the last event id of a captured event. */ + private _lastEventId?: string; + /** * Creates a new instance of the hub, will push one {@link Layer} into the * internal stack on creation. @@ -164,29 +168,57 @@ export class Hub { * Captures an exception event and sends it to Sentry. * * @param exception An exception-like object. - * @param syntheticException Manually thrown exception at the very top, to get _any_ valuable stack trace + * @param hint May contain additional informartion about the original exception. + * @returns The generated eventId. */ - public captureException(exception: any, syntheticException: Error | null = null): void { - this.invokeClientAsync('captureException', exception, syntheticException); + public captureException(exception: any, hint?: SentryEventHint): string { + const eventId = (this._lastEventId = uuid4()); + this.invokeClientAsync('captureException', exception, { + ...hint, + event_id: eventId, + }); + return eventId; } /** * Captures a message event and sends it to Sentry. * * @param message The message to send to Sentry. - * @param syntheticException Manually thrown exception at the very top, to get _any_ valuable stack trace + * @param level Define the level of the message. + * @param hint May contain additional informartion about the original exception. + * @returns The generated eventId. */ - public captureMessage(message: string, syntheticException: Error | null = null): void { - this.invokeClientAsync('captureMessage', message, syntheticException); + public captureMessage(message: string, level?: Severity, hint?: SentryEventHint): string { + const eventId = (this._lastEventId = uuid4()); + this.invokeClientAsync('captureMessage', message, level, { + ...hint, + event_id: eventId, + }); + return eventId; } /** * Captures a manually created event and sends it to Sentry. * * @param event The event to send to Sentry. + * @param hint May contain additional informartion about the original exception. + */ + public captureEvent(event: SentryEvent, hint?: SentryEventHint): string { + const eventId = (this._lastEventId = uuid4()); + this.invokeClientAsync('captureEvent', event, { + ...hint, + event_id: eventId, + }); + return eventId; + } + + /** + * This is the getter for lastEventId. + * + * @returns The last event id of a captured event. */ - public captureEvent(event: SentryEvent): void { - this.invokeClientAsync('captureEvent', event); + public lastEventId(): string | undefined { + return this._lastEventId; } /** @@ -213,15 +245,4 @@ export class Hub { callback(top.scope); } } - - /** - * This will be called to receive the event - * @param callback will only be called if there is a bound client - */ - public addEventProcessor(callback: (event: SentryEvent) => Promise): void { - const top = this.getStackTop(); - if (top.scope && top.client) { - top.scope.addEventProcessor(callback); - } - } } diff --git a/packages/hub/src/scope.ts b/packages/hub/src/scope.ts index 45edab8703ac..569a3b3a9e7a 100644 --- a/packages/hub/src/scope.ts +++ b/packages/hub/src/scope.ts @@ -1,4 +1,4 @@ -import { Breadcrumb, SentryEvent, User } from '@sentry/types'; +import { Breadcrumb, SentryEvent, SentryEventHint, User } from '@sentry/types'; /** * Holds additional event information. {@link Scope.applyToEvent} will be @@ -12,7 +12,7 @@ export class Scope { protected scopeListeners: Array<(scope: Scope) => void> = []; /** Callback list that will be called after {@link applyToEvent}. */ - protected eventProcessors: Array<(scope: SentryEvent) => Promise> = []; + protected eventProcessors: Array<(scope: SentryEvent, hint?: SentryEventHint) => Promise> = []; /** Array of breadcrumbs. */ protected breadcrumbs: Breadcrumb[] = []; @@ -35,7 +35,9 @@ export class Scope { } /** Add new event processor that will be called after {@link applyToEvent}. */ - public addEventProcessor(callback: (scope: SentryEvent) => Promise): void { + public addEventProcessor( + callback: (scope: SentryEvent, hint?: SentryEventHint) => Promise, + ): void { this.eventProcessors.push(callback); } @@ -57,13 +59,12 @@ export class Scope { /** * This will be called after {@link applyToEvent} is finished. */ - protected async notifyEventProcessors(event: SentryEvent): Promise { + protected async notifyEventProcessors(event: SentryEvent, hint?: SentryEventHint): Promise { let processedEvent: SentryEvent | null = event; for (const processor of this.eventProcessors) { try { - processedEvent = await processor({ ...processedEvent }); + processedEvent = await processor({ ...processedEvent }, hint); if (processedEvent === null) { - // tslint:disable-next-line:no-null-keyword return null; } } catch (e) { @@ -177,9 +178,14 @@ export class Scope { * Note that breadcrumbs will be added by the client. * Also if the event has already breadcrumbs on it, we do not merge them. * @param event SentryEvent + * @param hint May contain additional informartion about the original exception. * @param maxBreadcrumbs number of max breadcrumbs to merged into event. */ - public async applyToEvent(event: SentryEvent, maxBreadcrumbs?: number): Promise { + public async applyToEvent( + event: SentryEvent, + hint?: SentryEventHint, + maxBreadcrumbs?: number, + ): Promise { if (this.extra && Object.keys(this.extra).length) { event.extra = { ...this.extra, ...event.extra }; } @@ -201,6 +207,6 @@ export class Scope { : this.breadcrumbs; } - return this.notifyEventProcessors(event); + return this.notifyEventProcessors(event, hint); } } diff --git a/packages/hub/test/lib/hub.test.ts b/packages/hub/test/lib/hub.test.ts index d6192b0b9c61..54c13e980743 100644 --- a/packages/hub/test/lib/hub.test.ts +++ b/packages/hub/test/lib/hub.test.ts @@ -227,67 +227,6 @@ describe('Hub', () => { }); }); - test('addEventProcessor', async done => { - expect.assertions(2); - const event: SentryEvent = { - extra: { b: 3 }, - }; - const localScope = new Scope(); - localScope.setExtra('a', 'b'); - const hub = new Hub({ a: 'b' }, localScope); - hub.addEventProcessor(async (processedEvent: SentryEvent) => { - expect(processedEvent.extra).toEqual({ a: 'b', b: 3 }); - return processedEvent; - }); - hub.addEventProcessor(async (processedEvent: SentryEvent) => { - processedEvent.dist = '1'; - return processedEvent; - }); - hub.addEventProcessor(async (processedEvent: SentryEvent) => { - expect(processedEvent.dist).toEqual('1'); - done(); - return processedEvent; - }); - await localScope.applyToEvent(event); - }); - - test('addEventProcessor async', async () => { - expect.assertions(6); - const event: SentryEvent = { - extra: { b: 3 }, - }; - const localScope = new Scope(); - localScope.setExtra('a', 'b'); - const hub = new Hub({ a: 'b' }, localScope); - const callCounter = jest.fn(); - hub.addEventProcessor(async (processedEvent: SentryEvent) => { - callCounter(1); - expect(processedEvent.extra).toEqual({ a: 'b', b: 3 }); - return processedEvent; - }); - hub.addEventProcessor( - async (processedEvent: SentryEvent) => - new Promise(resolve => { - callCounter(2); - setTimeout(() => { - callCounter(3); - processedEvent.dist = '1'; - resolve(processedEvent); - }, 1); - }), - ); - hub.addEventProcessor(async (processedEvent: SentryEvent) => { - callCounter(4); - return processedEvent; - }); - const final = await localScope.applyToEvent(event); - expect(callCounter.mock.calls[0][0]).toBe(1); - expect(callCounter.mock.calls[1][0]).toBe(2); - expect(callCounter.mock.calls[2][0]).toBe(3); - expect(callCounter.mock.calls[3][0]).toBe(4); - expect(final.dist).toEqual('1'); - }); - test('pushScope inherit processors', async () => { const event: SentryEvent = { extra: { b: 3 }, @@ -296,7 +235,7 @@ describe('Hub', () => { localScope.setExtra('a', 'b'); const hub = new Hub({ a: 'b' }, localScope); const callCounter = jest.fn(); - hub.addEventProcessor(async (processedEvent: SentryEvent) => { + localScope.addEventProcessor(async (processedEvent: SentryEvent) => { callCounter(1); processedEvent.dist = '1'; return processedEvent; @@ -305,20 +244,40 @@ describe('Hub', () => { const pushedScope = hub.getStackTop().scope; if (pushedScope) { const final = await pushedScope.applyToEvent(event); - expect(final.dist).toEqual('1'); + expect(final!.dist).toEqual('1'); } }); - test('addEventProcessor return null', async () => { - expect.assertions(1); + test('captureException should set event_id in hint', () => { + const hub = new Hub(); + const spy = jest.spyOn(hub as any, 'invokeClientAsync'); + hub.captureException('a'); + expect(spy.mock.calls[0][2].event_id).toBeTruthy(); + }); + + test('captureMessage should set event_id in hint', () => { + const hub = new Hub(); + const spy = jest.spyOn(hub as any, 'invokeClientAsync'); + hub.captureMessage('a'); + expect(spy.mock.calls[0][3].event_id).toBeTruthy(); + }); + + test('captureEvent should set event_id in hint', () => { const event: SentryEvent = { extra: { b: 3 }, }; - const localScope = new Scope(); - localScope.setExtra('a', 'b'); - const hub = new Hub({ a: 'b' }, localScope); - hub.addEventProcessor(async (_: SentryEvent) => null); - const final = await localScope.applyToEvent(event); - expect(final).toBeNull(); + const hub = new Hub(); + const spy = jest.spyOn(hub as any, 'invokeClientAsync'); + hub.captureEvent(event); + expect(spy.mock.calls[0][2].event_id).toBeTruthy(); + }); + + test('lastEventId should be the same as last created', () => { + const event: SentryEvent = { + extra: { b: 3 }, + }; + const hub = new Hub(); + const eventId = hub.captureEvent(event); + expect(eventId).toBe(hub.lastEventId()); }); }); diff --git a/packages/hub/test/lib/scope.test.ts b/packages/hub/test/lib/scope.test.ts index 73cac52a3d6e..efbf14c3823c 100644 --- a/packages/hub/test/lib/scope.test.ts +++ b/packages/hub/test/lib/scope.test.ts @@ -1,7 +1,12 @@ -import { SentryEvent } from '@sentry/types'; +import { SentryEvent, SentryEventHint } from '@sentry/types'; import { Scope } from '../../src'; describe('Scope', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + }); + test('fingerprint', () => { const scope = new Scope(); scope.setFingerprint(['abcd']); @@ -117,4 +122,91 @@ describe('Scope', () => { scope.clear(); expect(scope.getExtra()).toEqual({}); }); + + test('addEventProcessor', async done => { + expect.assertions(2); + const event: SentryEvent = { + extra: { b: 3 }, + }; + const localScope = new Scope(); + localScope.setExtra('a', 'b'); + localScope.addEventProcessor(async (processedEvent: SentryEvent) => { + expect(processedEvent.extra).toEqual({ a: 'b', b: 3 }); + return processedEvent; + }); + localScope.addEventProcessor(async (processedEvent: SentryEvent) => { + processedEvent.dist = '1'; + return processedEvent; + }); + localScope.addEventProcessor(async (processedEvent: SentryEvent) => { + expect(processedEvent.dist).toEqual('1'); + done(); + return processedEvent; + }); + await localScope.applyToEvent(event); + }); + + test('addEventProcessor async', async () => { + expect.assertions(6); + const event: SentryEvent = { + extra: { b: 3 }, + }; + const localScope = new Scope(); + localScope.setExtra('a', 'b'); + const callCounter = jest.fn(); + localScope.addEventProcessor(async (processedEvent: SentryEvent) => { + callCounter(1); + expect(processedEvent.extra).toEqual({ a: 'b', b: 3 }); + return processedEvent; + }); + localScope.addEventProcessor( + async (processedEvent: SentryEvent) => + new Promise(resolve => { + callCounter(2); + setTimeout(() => { + callCounter(3); + processedEvent.dist = '1'; + resolve(processedEvent); + }, 1); + }), + ); + localScope.addEventProcessor(async (processedEvent: SentryEvent) => { + callCounter(4); + return processedEvent; + }); + const final = await localScope.applyToEvent(event); + expect(callCounter.mock.calls[0][0]).toBe(1); + expect(callCounter.mock.calls[1][0]).toBe(2); + expect(callCounter.mock.calls[2][0]).toBe(3); + expect(callCounter.mock.calls[3][0]).toBe(4); + expect(final!.dist).toEqual('1'); + }); + + test('addEventProcessor return null', async () => { + expect.assertions(1); + const event: SentryEvent = { + extra: { b: 3 }, + }; + const localScope = new Scope(); + localScope.setExtra('a', 'b'); + localScope.addEventProcessor(async (_: SentryEvent) => null); + const final = await localScope.applyToEvent(event); + expect(final).toBeNull(); + }); + + test('addEventProcessor pass along hint', async () => { + expect.assertions(3); + const event: SentryEvent = { + extra: { b: 3 }, + }; + const localScope = new Scope(); + localScope.setExtra('a', 'b'); + localScope.addEventProcessor(async (internalEvent: SentryEvent, hint?: SentryEventHint) => { + expect(hint).toBeTruthy(); + expect(hint!.syntheticException).toBeTruthy(); + return internalEvent; + }); + const final = await localScope.applyToEvent(event, { syntheticException: new Error('what') }); + expect(final).toEqual(event); + }); }); diff --git a/packages/minimal/src/index.ts b/packages/minimal/src/index.ts index cf29749e6111..894b9c452b63 100644 --- a/packages/minimal/src/index.ts +++ b/packages/minimal/src/index.ts @@ -1,55 +1,61 @@ import { getCurrentHub, Hub, Scope } from '@sentry/hub'; -import { Breadcrumb, SentryEvent } from '@sentry/types'; +import { Breadcrumb, SentryEvent, Severity } from '@sentry/types'; /** * This calls a function on the current hub. * @param method function to call on hub. * @param args to pass to function. */ -function callOnHub(method: string, ...args: any[]): void { +function callOnHub(method: string, ...args: any[]): T { const hub = getCurrentHub(); if (hub && hub[method as keyof Hub]) { - (hub[method as keyof Hub] as any)(...args); + // tslint:disable-next-line:no-unsafe-any + return (hub[method as keyof Hub] as any)(...args); } + throw new Error(`No hub defined or ${method} was not found on the hub, please open a bug report.`); } /** * Captures an exception event and sends it to Sentry. * * @param exception An exception-like object. + * @returns The generated eventId. */ -export function captureException(exception: any): void { +export function captureException(exception: any): string { let syntheticException: Error; try { throw new Error('Sentry syntheticException'); } catch (exception) { syntheticException = exception as Error; } - callOnHub('captureException', exception, syntheticException); + return callOnHub('captureException', exception, { syntheticException }); } /** * Captures a message event and sends it to Sentry. * * @param message The message to send to Sentry. + * @param level Define the level of the message. + * @returns The generated eventId. */ -export function captureMessage(message: string): void { +export function captureMessage(message: string, level?: Severity): string { let syntheticException: Error; try { throw new Error(message); } catch (exception) { syntheticException = exception as Error; } - callOnHub('captureMessage', message, syntheticException); + return callOnHub('captureMessage', message, level, { syntheticException }); } /** * Captures a manually created event and sends it to Sentry. * * @param event The event to send to Sentry. + * @returns The generated eventId. */ -export function captureEvent(event: SentryEvent): void { - callOnHub('captureEvent', event); +export function captureEvent(event: SentryEvent): string { + return callOnHub('captureEvent', event); } /** @@ -61,7 +67,7 @@ export function captureEvent(event: SentryEvent): void { * @param breadcrumb The breadcrumb to record. */ export function addBreadcrumb(breadcrumb: Breadcrumb): void { - callOnHub('addBreadcrumb', breadcrumb); + callOnHub('addBreadcrumb', breadcrumb); } /** @@ -69,7 +75,7 @@ export function addBreadcrumb(breadcrumb: Breadcrumb): void { * @param callback Callback function that receives Scope. */ export function configureScope(callback: (scope: Scope) => void): void { - callOnHub('configureScope', callback); + callOnHub('configureScope', callback); } /** @@ -82,5 +88,5 @@ export function configureScope(callback: (scope: Scope) => void): void { * @param args Arguments to pass to the client/fontend. */ export function _callOnClient(method: string, ...args: any[]): void { - callOnHub('invokeClient', method, ...args); + callOnHub('invokeClient', method, ...args); } diff --git a/packages/minimal/test/lib/minimal.test.ts b/packages/minimal/test/lib/minimal.test.ts index b6a6d52a594b..6adb4938470e 100644 --- a/packages/minimal/test/lib/minimal.test.ts +++ b/packages/minimal/test/lib/minimal.test.ts @@ -1,4 +1,4 @@ -import { getHubFromCarrier, getCurrentHub, Scope } from '@sentry/hub'; +import { getCurrentHub, getHubFromCarrier, Scope } from '@sentry/hub'; import { _callOnClient, addBreadcrumb, @@ -19,6 +19,18 @@ describe('Minimal', () => { }); describe('Capture', () => { + test('Return an event_id', () => { + const client = { + captureException: jest.fn(async () => Promise.resolve()), + }; + getCurrentHub().withScope(() => { + getCurrentHub().bindClient(client); + const e = new Error('test exception'); + const eventId = captureException(e); + expect(eventId).toBeTruthy(); + }); + }); + test('Exception', () => { const client = { captureException: jest.fn(async () => Promise.resolve()), diff --git a/packages/node/src/backend.ts b/packages/node/src/backend.ts index a74816533210..d35f51ccaa8b 100644 --- a/packages/node/src/backend.ts +++ b/packages/node/src/backend.ts @@ -1,6 +1,6 @@ import { Backend, DSN, Options, SentryError } from '@sentry/core'; import { getCurrentHub } from '@sentry/hub'; -import { SentryEvent, SentryResponse } from '@sentry/types'; +import { SentryEvent, SentryEventHint, SentryResponse, Severity, Transport } from '@sentry/types'; import { isError, isPlainObject } from '@sentry/utils/is'; import { limitObjectDepthToSize, serializeKeysToEventMessage } from '@sentry/utils/object'; import * as md5 from 'md5'; @@ -24,10 +24,13 @@ export class NodeBackend implements Backend { /** Creates a new Node backend instance. */ public constructor(private readonly options: NodeOptions = {}) {} + /** Cached transport used internally. */ + private transport?: Transport; + /** * @inheritDoc */ - public async eventFromException(exception: any, syntheticException: Error | null): Promise { + public async eventFromException(exception: any, hint?: SentryEventHint): Promise { let ex: any = exception; if (!isError(exception)) { @@ -42,31 +45,36 @@ export class NodeBackend implements Backend { scope.setFingerprint([md5(keys.join(''))]); }); - ex = syntheticException || new Error(message); + ex = (hint && hint.syntheticException) || new Error(message); (ex as Error).message = message; } else { // This handles when someone does: `throw "something awesome";` // We use synthesized Error here so we can extract a (rough) stack trace. - ex = syntheticException || new Error(exception as string); + ex = (hint && hint.syntheticException) || new Error(exception as string); } } const event: SentryEvent = await parseError(ex as Error); - return event; + return { + ...event, + event_id: hint && hint.event_id, + }; } /** * @inheritDoc */ - public async eventFromMessage(message: string, syntheticException: Error | null): Promise { + public async eventFromMessage(message: string, level?: Severity, hint?: SentryEventHint): Promise { const event: SentryEvent = { + event_id: hint && hint.event_id, fingerprint: [message], + level, message, }; - if (this.options.attachStacktrace && syntheticException) { - const stack = syntheticException ? await extractStackFromError(syntheticException) : []; + if (this.options.attachStacktrace && hint && hint.syntheticException) { + const stack = hint.syntheticException ? await extractStackFromError(hint.syntheticException) : []; const frames = await parseStack(stack); event.stacktrace = { frames: prepareFramesForEvent(frames), @@ -88,15 +96,16 @@ export class NodeBackend implements Backend { dsn = new DSN(this.options.dsn); } - const transportOptions = this.options.transportOptions ? this.options.transportOptions : { dsn }; - - const transport = this.options.transport - ? new this.options.transport({ dsn }) - : dsn.protocol === 'http' - ? new HTTPTransport(transportOptions) - : new HTTPSTransport(transportOptions); + if (!this.transport) { + const transportOptions = this.options.transportOptions ? this.options.transportOptions : { dsn }; + this.transport = this.options.transport + ? new this.options.transport({ dsn }) + : dsn.protocol === 'http' + ? new HTTPTransport(transportOptions) + : new HTTPSTransport(transportOptions); + } - return transport.send(event); + return this.transport.send(event); } /** diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index bcf821a70688..0a662a2c00ed 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -1,5 +1,5 @@ import { logger } from '@sentry/core'; -import { getHubFromCarrier } from '@sentry/hub'; +import { getHubFromCarrier, Scope } from '@sentry/hub'; import { SentryEvent, Severity } from '@sentry/types'; import { serialize } from '@sentry/utils/object'; import { parse as parseCookie } from 'cookie'; @@ -149,7 +149,9 @@ export function requestHandler(): (req: Request, res: Response, next: () => void const local = domain.create(); const hub = getHubFromCarrier(req); hub.bindClient(getCurrentHub().getClient()); - hub.addEventProcessor(async (event: SentryEvent) => parseRequest(event, req)); + hub.configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => parseRequest(event, req)); + }); local.on('error', next); local.run(next); }; @@ -190,7 +192,7 @@ export function errorHandler(): ( next(error); return; } - getHubFromCarrier(req).captureException(error); + getHubFromCarrier(req).captureException(error, { originalException: error }); next(error); }; } @@ -220,12 +222,14 @@ export function makeErrorHandler( caughtFirstError = true; getCurrentHub().withScope(async () => { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => ({ - ...event, - level: Severity.Fatal, - })); + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => ({ + ...event, + level: Severity.Fatal, + })); + }); - getCurrentHub().captureException(error); + getCurrentHub().captureException(error, { originalException: error }); if (!calledFatalError) { calledFatalError = true; diff --git a/packages/node/src/integrations/clientoptions.ts b/packages/node/src/integrations/clientoptions.ts index fde439bf9b56..3baa2205c243 100644 --- a/packages/node/src/integrations/clientoptions.ts +++ b/packages/node/src/integrations/clientoptions.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/hub'; +import { getCurrentHub, Scope } from '@sentry/hub'; import { Integration, SentryEvent } from '@sentry/types'; import { NodeOptions } from '../backend'; @@ -13,17 +13,19 @@ export class ClientOptions implements Integration { * @inheritDoc */ public install(options: NodeOptions = {}): void { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => { - const preparedEvent: SentryEvent = { - ...event, - platform: 'node', - }; + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => { + const preparedEvent: SentryEvent = { + ...event, + platform: 'node', + }; - if (options.serverName) { - event.server_name = options.serverName; - } + if (options.serverName) { + event.server_name = options.serverName; + } - return preparedEvent; + return preparedEvent; + }); }); } } diff --git a/packages/node/src/integrations/onunhandledrejection.ts b/packages/node/src/integrations/onunhandledrejection.ts index 01f22bec33b3..3bb6e0ac1073 100644 --- a/packages/node/src/integrations/onunhandledrejection.ts +++ b/packages/node/src/integrations/onunhandledrejection.ts @@ -39,7 +39,7 @@ export class OnUnhandledRejection implements Integration { } scope.setExtra('unhandledPromiseRejection', true); }); - getCurrentHub().captureException(reason); + getCurrentHub().captureException(reason, { originalException: promise }); }); } } diff --git a/packages/node/src/integrations/sdkinformation.ts b/packages/node/src/integrations/sdkinformation.ts index 7ec562fe7928..5ae5b6417042 100644 --- a/packages/node/src/integrations/sdkinformation.ts +++ b/packages/node/src/integrations/sdkinformation.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/hub'; +import { getCurrentHub, Scope } from '@sentry/hub'; import { Integration, SentryEvent } from '@sentry/types'; import { SDK_NAME, SDK_VERSION } from '../version'; @@ -13,19 +13,21 @@ export class SDKInformation implements Integration { * @inheritDoc */ public install(): void { - getCurrentHub().addEventProcessor(async (event: SentryEvent) => ({ - ...event, - sdk: { - name: SDK_NAME, - packages: [ - ...((event.sdk && event.sdk.packages) || []), - { - name: 'npm:@sentry/node', - version: SDK_VERSION, - }, - ], - version: SDK_VERSION, - }, - })); + getCurrentHub().configureScope((scope: Scope) => { + scope.addEventProcessor(async (event: SentryEvent) => ({ + ...event, + sdk: { + name: SDK_NAME, + packages: [ + ...((event.sdk && event.sdk.packages) || []), + { + name: 'npm:@sentry/node', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }, + })); + }); } } diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 4a12e877edda..b0e65496f5ed 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -75,11 +75,12 @@ describe('SentryNode', () => { test('record auto breadcrumbs', done => { getCurrentHub().pushScope(); const client = new NodeClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { // TODO: It should be 3, but we don't capture a breadcrumb // for our own captureMessage/captureException calls yet expect(event.breadcrumbs!).toHaveLength(2); done(); + return event; }, dsn, }); @@ -107,7 +108,7 @@ describe('SentryNode', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new NodeClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.tags).toEqual({ test: '1' }); expect(event.exception).not.toBeUndefined(); expect(event.exception!.values[0]).not.toBeUndefined(); @@ -115,6 +116,7 @@ describe('SentryNode', () => { expect(event.exception!.values[0].value).toBe('test'); expect(event.exception!.values[0].stacktrace).toBeTruthy(); done(); + return event; }, dsn, }), @@ -135,10 +137,11 @@ describe('SentryNode', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new NodeClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.message).toBe('test'); expect(event.exception).toBeUndefined(); done(); + return event; }, dsn, }), @@ -152,10 +155,11 @@ describe('SentryNode', () => { getCurrentHub().pushScope(); getCurrentHub().bindClient( new NodeClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.message).toBe('test'); expect(event.exception).toBeUndefined(); done(); + return event; }, dsn, }), @@ -168,11 +172,12 @@ describe('SentryNode', () => { new Promise(resolve => { const d = domain.create(); const client = new NodeClient({ - afterSend: (event: SentryEvent) => { + beforeSend: (event: SentryEvent) => { expect(event.message).toBe('test'); expect(event.exception).toBeUndefined(); resolve(); d.exit(); + return event; }, dsn, }); diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index 1f33a1e1eb47..2a7af9fb292b 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -29,7 +29,7 @@ jest.mock('http', () => ({ })); import { DSN, SentryError } from '@sentry/core'; -import { captureMessage, init, NodeClient, SentryEvent } from '../../src'; +import { getCurrentHub, init, NodeClient } from '../../src'; const dsn = 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622'; @@ -39,16 +39,14 @@ describe('HTTPTransport', () => { mockReturnCode = 200; }); - test('send 200', done => { + test('send 200', async () => { init({ - afterSend: (event: SentryEvent) => { - expect(mockSetEncoding).toHaveBeenCalled(); - expect(event.message).toBe('test'); - done(); - }, dsn, }); - captureMessage('test'); + await getCurrentHub() + .getClient() + .captureMessage('test'); + expect(mockSetEncoding).toHaveBeenCalled(); }); test('send 400', async () => { diff --git a/packages/node/test/transports/https.test.ts b/packages/node/test/transports/https.test.ts index d8cbdba7b40a..92421931c8dc 100644 --- a/packages/node/test/transports/https.test.ts +++ b/packages/node/test/transports/https.test.ts @@ -29,7 +29,7 @@ jest.mock('https', () => ({ })); import { DSN, SentryError } from '@sentry/core'; -import { captureMessage, init, NodeClient, SentryEvent } from '../../src'; +import { getCurrentHub, init, NodeClient } from '../../src'; const dsn = 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622'; @@ -39,16 +39,14 @@ describe('HTTPSTransport', () => { mockReturnCode = 200; }); - test('send 200', done => { + test('send 200', async () => { init({ - afterSend: (event: SentryEvent) => { - expect(mockSetEncoding).toHaveBeenCalled(); - expect(event.message).toBe('test'); - done(); - }, dsn, }); - captureMessage('test'); + await getCurrentHub() + .getClient() + .captureMessage('test'); + expect(mockSetEncoding).toHaveBeenCalled(); }); test('send 400', async () => { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b7378b781697..eecde01dd5aa 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -210,6 +210,7 @@ export interface Integration { /** JSDoc */ export interface SentryResponse { status: Status; + event?: SentryEvent; } /** JSDoc */ @@ -281,3 +282,11 @@ export interface SentryWrappedFunction extends Function { __sentry_wrapper__?: SentryWrappedFunction; __sentry_original__?: SentryWrappedFunction; } + +/** JSDoc */ +export interface SentryEventHint { + event_id?: string; + syntheticException?: Error | null; + originalException?: Error | null; + data?: any; +} diff --git a/yarn.lock b/yarn.lock index 24032d622ebd..08cd09e28b2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1979,6 +1979,10 @@ colors@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.0.tgz#5f20c9fef6945cb1134260aab33bfbdc8295e04e" +colors@~0.6.0: + version "0.6.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" + columnify@^1.5.4: version "1.5.4" resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.5.4.tgz#4737ddf1c7b69a8a7c340570782e947eec8e78bb" @@ -4604,6 +4608,16 @@ karma-coverage@^1.1.1: minimatch "^3.0.0" source-map "^0.5.1" +karma-failed-reporter@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/karma-failed-reporter/-/karma-failed-reporter-0.0.3.tgz#4532ec9652c9fe297d0b72d08d9cade9725ef733" + dependencies: + colors "~0.6.0" + +karma-firefox-launcher@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/karma-firefox-launcher/-/karma-firefox-launcher-1.1.0.tgz#2c47030452f04531eb7d13d4fc7669630bb93339" + karma-mocha-reporter@^2.2.5: version "2.2.5" resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"