diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 1d1ad6aaa377..1a15c4bc37bd 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -12,6 +12,7 @@ import type { Event, EventDropReason, EventHint, + EventProcessor, Integration, IntegrationClass, Outcome, @@ -107,6 +108,8 @@ export abstract class BaseClient implements Client { // eslint-disable-next-line @typescript-eslint/ban-types private _hooks: Record; + private _eventProcessors: EventProcessor[]; + /** * Initializes this client instance. * @@ -119,6 +122,7 @@ export abstract class BaseClient implements Client { this._numProcessing = 0; this._outcomes = {}; this._hooks = {}; + this._eventProcessors = []; if (options.dsn) { this._dsn = makeDsn(options.dsn); @@ -280,6 +284,16 @@ export abstract class BaseClient implements Client { }); } + /** Get all installed event processors. */ + public getEventProcessors(): EventProcessor[] { + return this._eventProcessors; + } + + /** @inheritDoc */ + public addEventProcessor(eventProcessor: EventProcessor): void { + this._eventProcessors.push(eventProcessor); + } + /** * Sets up the integrations */ @@ -545,7 +559,7 @@ export abstract class BaseClient implements Client { this.emit('preprocessEvent', event, hint); - return prepareEvent(options, event, hint, scope).then(evt => { + return prepareEvent(options, event, hint, scope, this).then(evt => { if (evt === null) { return evt; } diff --git a/packages/core/src/eventProcessors.ts b/packages/core/src/eventProcessors.ts new file mode 100644 index 000000000000..4596788b9dcb --- /dev/null +++ b/packages/core/src/eventProcessors.ts @@ -0,0 +1,51 @@ +import type { Event, EventHint, EventProcessor } from '@sentry/types'; +import { getGlobalSingleton, isThenable, logger, SyncPromise } from '@sentry/utils'; + +/** + * Returns the global event processors. + */ +export function getGlobalEventProcessors(): EventProcessor[] { + return getGlobalSingleton('globalEventProcessors', () => []); +} + +/** + * Add a EventProcessor to be kept globally. + * @param callback EventProcessor to add + */ +export function addGlobalEventProcessor(callback: EventProcessor): void { + getGlobalEventProcessors().push(callback); +} + +/** + * Process an array of event processors, returning the processed event (or `null` if the event was dropped). + */ +export function notifyEventProcessors( + processors: EventProcessor[], + event: Event | null, + hint: EventHint, + index: number = 0, +): PromiseLike { + return new SyncPromise((resolve, reject) => { + const processor = processors[index]; + if (event === null || typeof processor !== 'function') { + resolve(event); + } else { + const result = processor({ ...event }, hint) as Event | null; + + __DEBUG_BUILD__ && + processor.id && + result === null && + logger.log(`Event processor "${processor.id}" dropped event`); + + if (isThenable(result)) { + void result + .then(final => notifyEventProcessors(processors, final, hint, index + 1).then(resolve)) + .then(null, reject); + } else { + void notifyEventProcessors(processors, result, hint, index + 1) + .then(resolve) + .then(null, reject); + } + } + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67c28a3e3c57..21f7cab37505 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,7 +36,8 @@ export { } from './hub'; export { makeSession, closeSession, updateSession } from './session'; export { SessionFlusher } from './sessionflusher'; -export { addGlobalEventProcessor, Scope } from './scope'; +export { Scope } from './scope'; +export { addGlobalEventProcessor } from './eventProcessors'; export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; export { BaseClient } from './baseclient'; export { ServerRuntimeClient } from './server-runtime-client'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index b2c8e4547ab8..aa8968edc8dc 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,8 +1,8 @@ -import type { Client, Integration, Options } from '@sentry/types'; +import type { Client, Event, EventHint, Integration, Options } from '@sentry/types'; import { arrayify, logger } from '@sentry/utils'; +import { addGlobalEventProcessor } from './eventProcessors'; import { getCurrentHub } from './hub'; -import { addGlobalEventProcessor } from './scope'; declare module '@sentry/types' { interface Integration { @@ -107,10 +107,20 @@ export function setupIntegration(client: Client, integration: Integration, integ } if (client.on && typeof integration.preprocessEvent === 'function') { - const callback = integration.preprocessEvent.bind(integration); + const callback = integration.preprocessEvent.bind(integration) as typeof integration.preprocessEvent; client.on('preprocessEvent', (event, hint) => callback(event, hint, client)); } + if (client.addEventProcessor && typeof integration.processEvent === 'function') { + const callback = integration.processEvent.bind(integration) as typeof integration.processEvent; + + const processor = Object.assign((event: Event, hint: EventHint) => callback(event, hint, client), { + id: integration.name, + }); + + client.addEventProcessor(processor); + } + __DEBUG_BUILD__ && logger.log(`Integration installed: ${integration.name}`); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index ef6832bc773d..ba4ccac23adb 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,17 +22,9 @@ import type { Transaction, User, } from '@sentry/types'; -import { - arrayify, - dateTimestampInSeconds, - getGlobalSingleton, - isPlainObject, - isThenable, - logger, - SyncPromise, - uuid4, -} from '@sentry/utils'; +import { arrayify, dateTimestampInSeconds, isPlainObject, uuid4 } from '@sentry/utils'; +import { getGlobalEventProcessors, notifyEventProcessors } from './eventProcessors'; import { updateSession } from './session'; /** @@ -525,7 +517,7 @@ export class Scope implements ScopeInterface { propagationContext: this._propagationContext, }; - return this._notifyEventProcessors([...getGlobalEventProcessors(), ...this._eventProcessors], event, hint); + return notifyEventProcessors([...getGlobalEventProcessors(), ...this._eventProcessors], event, hint); } /** @@ -559,40 +551,6 @@ export class Scope implements ScopeInterface { return this._breadcrumbs; } - /** - * This will be called after {@link applyToEvent} is finished. - */ - protected _notifyEventProcessors( - processors: EventProcessor[], - event: Event | null, - hint: EventHint, - index: number = 0, - ): PromiseLike { - return new SyncPromise((resolve, reject) => { - const processor = processors[index]; - if (event === null || typeof processor !== 'function') { - resolve(event); - } else { - const result = processor({ ...event }, hint) as Event | null; - - __DEBUG_BUILD__ && - processor.id && - result === null && - logger.log(`Event processor "${processor.id}" dropped event`); - - if (isThenable(result)) { - void result - .then(final => this._notifyEventProcessors(processors, final, hint, index + 1).then(resolve)) - .then(null, reject); - } else { - void this._notifyEventProcessors(processors, result, hint, index + 1) - .then(resolve) - .then(null, reject); - } - } - }); - } - /** * This will be called on every set call. */ @@ -629,21 +587,6 @@ export class Scope implements ScopeInterface { } } -/** - * Returns the global event processors. - */ -function getGlobalEventProcessors(): EventProcessor[] { - return getGlobalSingleton('globalEventProcessors', () => []); -} - -/** - * Add a EventProcessor to be kept globally. - * @param callback EventProcessor to add - */ -export function addGlobalEventProcessor(callback: EventProcessor): void { - getGlobalEventProcessors().push(callback); -} - function generatePropagationContext(): PropagationContext { return { traceId: uuid4(), diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 84bfd404c56f..13a164dbaf76 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -1,7 +1,8 @@ -import type { ClientOptions, Event, EventHint, StackFrame, StackParser } from '@sentry/types'; +import type { Client, ClientOptions, Event, EventHint, StackFrame, StackParser } from '@sentry/types'; import { dateTimestampInSeconds, GLOBAL_OBJ, normalize, resolvedSyncPromise, truncate, uuid4 } from '@sentry/utils'; import { DEFAULT_ENVIRONMENT } from '../constants'; +import { notifyEventProcessors } from '../eventProcessors'; import { Scope } from '../scope'; /** @@ -26,6 +27,7 @@ export function prepareEvent( event: Event, hint: EventHint, scope?: Scope, + client?: Client, ): PromiseLike { const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = options; const prepared: Event = { @@ -74,20 +76,25 @@ export function prepareEvent( result = finalScope.applyToEvent(prepared, hint); } - return result.then(evt => { - if (evt) { - // We apply the debug_meta field only after all event processors have ran, so that if any event processors modified - // file names (e.g.the RewriteFrames integration) the filename -> debug ID relationship isn't destroyed. - // This should not cause any PII issues, since we're only moving data that is already on the event and not adding - // any new data - applyDebugMeta(evt); - } + return result + .then(evt => { + // Process client-scoped event processors + return client && client.getEventProcessors ? notifyEventProcessors(client.getEventProcessors(), evt, hint) : evt; + }) + .then(evt => { + if (evt) { + // We apply the debug_meta field only after all event processors have ran, so that if any event processors modified + // file names (e.g.the RewriteFrames integration) the filename -> debug ID relationship isn't destroyed. + // This should not cause any PII issues, since we're only moving data that is already on the event and not adding + // any new data + applyDebugMeta(evt); + } - if (typeof normalizeDepth === 'number' && normalizeDepth > 0) { - return normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth); - } - return evt; - }); + if (typeof normalizeDepth === 'number' && normalizeDepth > 0) { + return normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth); + } + return evt; + }); } /** diff --git a/packages/core/test/lib/integration.test.ts b/packages/core/test/lib/integration.test.ts index 14b1697b9054..f431d30b2140 100644 --- a/packages/core/test/lib/integration.test.ts +++ b/packages/core/test/lib/integration.test.ts @@ -1,6 +1,15 @@ import type { Integration, Options } from '@sentry/types'; -import { getIntegrationsToSetup } from '../../src/integration'; +import { getIntegrationsToSetup, installedIntegrations, setupIntegration } from '../../src/integration'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +function getTestClient(): TestClient { + return new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + }), + ); +} /** JSDoc */ class MockIntegration implements Integration { @@ -317,3 +326,236 @@ describe('getIntegrationsToSetup', () => { expect(integrations.map(i => i.name)).toEqual(['foo', 'Debug']); }); }); + +describe('setupIntegration', () => { + beforeEach(function () { + // Reset the (global!) list of installed integrations + installedIntegrations.splice(0, installedIntegrations.length); + }); + + it('works with a minimal integration', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + } + + const client = getTestClient(); + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration }); + expect(integration.setupOnce).toHaveBeenCalledTimes(1); + }); + + it('only calls setupOnce a single time', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + }); + + it('binds preprocessEvent for each client', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + preprocessEvent = jest.fn(); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + + client1.captureEvent({ event_id: '1a' }); + client1.captureEvent({ event_id: '1b' }); + client2.captureEvent({ event_id: '2a' }); + client2.captureEvent({ event_id: '2b' }); + client2.captureEvent({ event_id: '2c' }); + + expect(integration1.preprocessEvent).toHaveBeenCalledTimes(2); + expect(integration2.preprocessEvent).toHaveBeenCalledTimes(2); + expect(integration3.preprocessEvent).toHaveBeenCalledTimes(3); + expect(integration4.preprocessEvent).toHaveBeenCalledTimes(3); + + expect(integration1.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '1b' }, {}, client1); + expect(integration2.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '1b' }, {}, client1); + expect(integration3.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '2c' }, {}, client2); + expect(integration4.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '2c' }, {}, client2); + }); + + it('allows to mutate events in preprocessEvent', async () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + preprocessEvent = jest.fn(event => { + event.event_id = 'mutated'; + }); + } + + const client = getTestClient(); + + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + const sendEvent = jest.fn(); + client.sendEvent = sendEvent; + + client.captureEvent({ event_id: '1a' }); + await client.flush(); + + expect(sendEvent).toHaveBeenCalledTimes(1); + expect(sendEvent).toHaveBeenCalledWith(expect.objectContaining({ event_id: 'mutated' }), {}); + }); + + it('binds processEvent for each client', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + processEvent = jest.fn(event => { + return event; + }); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + + client1.captureEvent({ event_id: '1a' }); + client1.captureEvent({ event_id: '1b' }); + client2.captureEvent({ event_id: '2a' }); + client2.captureEvent({ event_id: '2b' }); + client2.captureEvent({ event_id: '2c' }); + + expect(integration1.processEvent).toHaveBeenCalledTimes(2); + expect(integration2.processEvent).toHaveBeenCalledTimes(2); + expect(integration3.processEvent).toHaveBeenCalledTimes(3); + expect(integration4.processEvent).toHaveBeenCalledTimes(3); + + expect(integration1.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '1b' }), + {}, + client1, + ); + expect(integration2.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '1b' }), + {}, + client1, + ); + expect(integration3.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '2c' }), + {}, + client2, + ); + expect(integration4.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '2c' }), + {}, + client2, + ); + }); + + it('allows to mutate events in processEvent', async () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + processEvent = jest.fn(_event => { + return { event_id: 'mutated' }; + }); + } + + const client = getTestClient(); + + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + const sendEvent = jest.fn(); + client.sendEvent = sendEvent; + + client.captureEvent({ event_id: '1a' }); + await client.flush(); + + expect(sendEvent).toHaveBeenCalledTimes(1); + expect(sendEvent).toHaveBeenCalledWith(expect.objectContaining({ event_id: 'mutated' }), {}); + }); + + it('allows to drop events in processEvent', async () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + processEvent = jest.fn(_event => { + return null; + }); + } + + const client = getTestClient(); + + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + const sendEvent = jest.fn(); + client.sendEvent = sendEvent; + + client.captureEvent({ event_id: '1a' }); + await client.flush(); + + expect(sendEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 00361d0ada99..1b7b78066f0c 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -5,6 +5,7 @@ import type { DataCategory } from './datacategory'; import type { DsnComponents } from './dsn'; import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; +import type { EventProcessor } from './eventprocessor'; import type { Integration, IntegrationClass } from './integration'; import type { ClientOptions } from './options'; import type { Scope } from './scope'; @@ -120,6 +121,20 @@ export interface Client { */ flush(timeout?: number): PromiseLike; + /** + * Adds an event processor that applies to any event processed by this client. + * + * TODO (v8): Make this a required method. + */ + addEventProcessor?(eventProcessor: EventProcessor): void; + + /** + * Get all added event processors for this client. + * + * TODO (v8): Make this a required method. + */ + getEventProcessors?(): EventProcessor[]; + /** Returns the client's instance of the given integration class, it any. */ getIntegration(integration: IntegrationClass): T | null; diff --git a/packages/types/src/integration.ts b/packages/types/src/integration.ts index 0c1feae65323..19df0b9e67c2 100644 --- a/packages/types/src/integration.ts +++ b/packages/types/src/integration.ts @@ -30,4 +30,11 @@ export interface Integration { * An optional hook that allows to preprocess an event _before_ it is passed to all other event processors. */ preprocessEvent?(event: Event, hint: EventHint | undefined, client: Client): void; + + /** + * An optional hook that allows to process an event. + * Return `null` to drop the event, or mutate the event & return it. + * This receives the client that the integration was installed for as third argument. + */ + processEvent?(event: Event, hint: EventHint | undefined, client: Client): Event | null | PromiseLike; }