diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index b551cfafb8e8..bf1ae616c4d6 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -17,6 +17,7 @@ import type { SessionAggregates, Severity, SeverityLevel, + Transaction, TransactionEvent, Transport, } from '@sentry/types'; @@ -97,6 +98,9 @@ export abstract class BaseClient implements Client { /** Holds flushable */ private _outcomes: { [key: string]: number } = {}; + // eslint-disable-next-line @typescript-eslint/ban-types + private _hooks: Record = {}; + /** * Initializes this client instance. * @@ -351,6 +355,38 @@ export abstract class BaseClient implements Client { } } + // Keep on() & emit() signatures in sync with types' client.ts interface + + /** @inheritdoc */ + public on(hook: 'startTransaction' | 'finishTransaction', callback: (transaction: Transaction) => void): void; + + /** @inheritdoc */ + public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void; + + /** @inheritdoc */ + public on(hook: string, callback: unknown): void { + if (!this._hooks[hook]) { + this._hooks[hook] = []; + } + + // @ts-ignore We assue the types are correct + this._hooks[hook].push(callback); + } + + /** @inheritdoc */ + public emit(hook: 'startTransaction' | 'finishTransaction', transaction: Transaction): void; + + /** @inheritdoc */ + public emit(hook: 'beforeEnvelope', envelope: Envelope): void; + + /** @inheritdoc */ + public emit(hook: string, ...rest: unknown[]): void { + if (this._hooks[hook]) { + // @ts-ignore we cannot enforce the callback to match the hook + this._hooks[hook].forEach(callback => callback(...rest)); + } + } + /** Updates existing session based on the provided event */ protected _updateSessionFromEvent(session: Session, event: Event): void { let crashed = false; diff --git a/packages/core/test/lib/base.test.ts b/packages/core/test/lib/base.test.ts index d7382ceeeacb..4ee65114fe79 100644 --- a/packages/core/test/lib/base.test.ts +++ b/packages/core/test/lib/base.test.ts @@ -1,4 +1,4 @@ -import type { Event, Span } from '@sentry/types'; +import type { Client, Envelope, Event, Span, Transaction } from '@sentry/types'; import { dsnToString, logger, SentryError, SyncPromise } from '@sentry/utils'; import { Hub, makeSession, Scope } from '../../src'; @@ -1730,4 +1730,47 @@ describe('BaseClient', () => { expect(clearedOutcomes4.length).toEqual(0); }); }); + + describe('hooks', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + + // Make sure types work for both Client & BaseClient + const scenarios = [ + ['BaseClient', new TestClient(options)], + ['Client', new TestClient(options) as Client], + ] as const; + + describe.each(scenarios)('with client %s', (_, client) => { + it('should call a startTransaction hook', () => { + expect.assertions(1); + + const mockTransaction = { + traceId: '86f39e84263a4de99c326acab3bfe3bd', + } as Transaction; + + client.on?.('startTransaction', transaction => { + expect(transaction).toEqual(mockTransaction); + }); + + client.emit?.('startTransaction', mockTransaction); + }); + + it('should call a beforeEnvelope hook', () => { + expect.assertions(1); + + const mockEnvelope = [ + { + event_id: '12345', + }, + {}, + ] as Envelope; + + client.on?.('beforeEnvelope', envelope => { + expect(envelope).toEqual(mockEnvelope); + }); + + client.emit?.('beforeEnvelope', mockEnvelope); + }); + }); + }); }); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 28d23025ce84..71e2ac5a96d6 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -1,6 +1,7 @@ import type { EventDropReason } from './clientreport'; import type { DataCategory } from './datacategory'; import type { DsnComponents } from './dsn'; +import type { Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { Integration, IntegrationClass } from './integration'; import type { ClientOptions } from './options'; @@ -8,6 +9,7 @@ import type { Scope } from './scope'; import type { SdkMetadata } from './sdkmetadata'; import type { Session, SessionAggregates } from './session'; import type { Severity, SeverityLevel } from './severity'; +import type { Transaction } from './transaction'; import type { Transport } from './transport'; /** @@ -147,4 +149,29 @@ export interface Client { * @param event The dropped event. */ recordDroppedEvent(reason: EventDropReason, dataCategory: DataCategory, event?: Event): void; + + // HOOKS + // TODO(v8): Make the hooks non-optional. + + /** + * Register a callback for transaction start and finish. + */ + on?(hook: 'startTransaction' | 'finishTransaction', callback: (transaction: Transaction) => void): void; + + /** + * Register a callback for transaction start and finish. + */ + on?(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void; + + /** + * Fire a hook event for transaction start and finish. Expects to be given a transaction as the + * second argument. + */ + emit?(hook: 'startTransaction' | 'finishTransaction', transaction: Transaction): void; + + /* + * Fire a hook event for envelope creation and sending. Expects to be given an envelope as the + * second argument. + */ + emit?(hook: 'beforeEnvelope', envelope: Envelope): void; }