diff --git a/packages/browser/package.json b/packages/browser/package.json index d9274e5f0303..0ed80935b9b2 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -66,7 +66,7 @@ "size:check": "run-p size:check:es5 size:check:es6", "size:check:es5": "cat build/bundles/bundle.min.js | gzip -9 | wc -c | awk '{$1=$1/1024; print \"ES5: \",$1,\"kB\";}'", "size:check:es6": "cat build/bundles/bundle.es6.min.js | gzip -9 | wc -c | awk '{$1=$1/1024; print \"ES6: \",$1,\"kB\";}'", - "test": "run-s test:unit", + "test": "yarn test:unit", "test:unit": "jest", "test:integration": "test/integration/run.js", "test:integration:checkbrowsers": "node scripts/checkbrowsers.js", diff --git a/packages/browser/test/unit/index.test.ts b/packages/browser/test/unit/index.test.ts index 37fa972512a1..8a59db3bdd46 100644 --- a/packages/browser/test/unit/index.test.ts +++ b/packages/browser/test/unit/index.test.ts @@ -180,6 +180,19 @@ describe('SentryBrowser', () => { captureEvent({ message: 'event' }); }); + it('should set `platform` on events', done => { + const options = getDefaultBrowserClientOptions({ + beforeSend: (event: Event): Event | null => { + expect(event.platform).toBe('javascript'); + done(); + return event; + }, + dsn, + }); + getCurrentHub().bindClient(new BrowserClient(options)); + captureEvent({ message: 'event' }); + }); + it('should not dedupe an event on bound client', async () => { const localBeforeSend = jest.fn(); const options = getDefaultBrowserClientOptions({ diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index e8d1de2e3ca3..8ad53f017f9d 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -23,19 +23,15 @@ import { addItemToEnvelope, checkOrSetAlreadyCaught, createAttachmentEnvelopeItem, - dateTimestampInSeconds, isPlainObject, isPrimitive, isThenable, logger, makeDsn, - normalize, rejectedSyncPromise, resolvedSyncPromise, SentryError, SyncPromise, - truncate, - uuid4, } from '@sentry/utils'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; @@ -43,6 +39,7 @@ import { createEventEnvelope, createSessionEnvelope } from './envelope'; import { IntegrationIndex, setupIntegrations } from './integration'; import { Scope } from './scope'; import { updateSession } from './session'; +import { prepareEvent } from './utils/prepareEvent'; const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; @@ -322,7 +319,7 @@ export abstract class BaseClient implements Client { // Note: we use `event` in replay, where we overwrite this hook. if (this._options.sendClientReports) { - // We want to track each category (error, transaction, session) separately + // We want to track each category (error, transaction, session, replay_event) separately // but still keep the distinction between different type of outcomes. // We could use nested maps, but it's much easier to read and type this way. // A correct type for map-based implementation if we want to go that route @@ -419,166 +416,8 @@ export abstract class BaseClient implements Client { * @returns A new event with more information. */ protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { - const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = this.getOptions(); - const prepared: Event = { - ...event, - event_id: event.event_id || hint.event_id || uuid4(), - timestamp: event.timestamp || dateTimestampInSeconds(), - }; - - this._applyClientOptions(prepared); - this._applyIntegrationsMetadata(prepared); - - // If we have scope given to us, use it as the base for further modifications. - // This allows us to prevent unnecessary copying of data if `captureContext` is not provided. - let finalScope = scope; - if (hint.captureContext) { - finalScope = Scope.clone(finalScope).update(hint.captureContext); - } - - // We prepare the result here with a resolved Event. - let result = resolvedSyncPromise(prepared); - - // This should be the last thing called, since we want that - // {@link Hub.addEventProcessor} gets the finished prepared event. - // - // We need to check for the existence of `finalScope.getAttachments` - // because `getAttachments` can be undefined if users are using an older version - // of `@sentry/core` that does not have the `getAttachments` method. - // See: https://github.com/getsentry/sentry-javascript/issues/5229 - if (finalScope && finalScope.getAttachments) { - // Collect attachments from the hint and scope - const attachments = [...(hint.attachments || []), ...finalScope.getAttachments()]; - - if (attachments.length) { - hint.attachments = attachments; - } - - // In case we have a hub we reassign it. - result = finalScope.applyToEvent(prepared, hint); - } - - return result.then(evt => { - if (typeof normalizeDepth === 'number' && normalizeDepth > 0) { - return this._normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth); - } - return evt; - }); - } - - /** - * Applies `normalize` function on necessary `Event` attributes to make them safe for serialization. - * Normalized keys: - * - `breadcrumbs.data` - * - `user` - * - `contexts` - * - `extra` - * @param event Event - * @returns Normalized event - */ - protected _normalizeEvent(event: Event | null, depth: number, maxBreadth: number): Event | null { - if (!event) { - return null; - } - - const normalized: Event = { - ...event, - ...(event.breadcrumbs && { - breadcrumbs: event.breadcrumbs.map(b => ({ - ...b, - ...(b.data && { - data: normalize(b.data, depth, maxBreadth), - }), - })), - }), - ...(event.user && { - user: normalize(event.user, depth, maxBreadth), - }), - ...(event.contexts && { - contexts: normalize(event.contexts, depth, maxBreadth), - }), - ...(event.extra && { - extra: normalize(event.extra, depth, maxBreadth), - }), - }; - - // event.contexts.trace stores information about a Transaction. Similarly, - // event.spans[] stores information about child Spans. Given that a - // Transaction is conceptually a Span, normalization should apply to both - // Transactions and Spans consistently. - // For now the decision is to skip normalization of Transactions and Spans, - // so this block overwrites the normalized event to add back the original - // Transaction information prior to normalization. - if (event.contexts && event.contexts.trace && normalized.contexts) { - normalized.contexts.trace = event.contexts.trace; - - // event.contexts.trace.data may contain circular/dangerous data so we need to normalize it - if (event.contexts.trace.data) { - normalized.contexts.trace.data = normalize(event.contexts.trace.data, depth, maxBreadth); - } - } - - // event.spans[].data may contain circular/dangerous data so we need to normalize it - if (event.spans) { - normalized.spans = event.spans.map(span => { - // We cannot use the spread operator here because `toJSON` on `span` is non-enumerable - if (span.data) { - span.data = normalize(span.data, depth, maxBreadth); - } - return span; - }); - } - - return normalized; - } - - /** - * Enhances event using the client configuration. - * It takes care of all "static" values like environment, release and `dist`, - * as well as truncating overly long values. - * @param event event instance to be enhanced - */ - protected _applyClientOptions(event: Event): void { const options = this.getOptions(); - const { environment, release, dist, maxValueLength = 250 } = options; - - if (!('environment' in event)) { - event.environment = 'environment' in options ? environment : 'production'; - } - - if (event.release === undefined && release !== undefined) { - event.release = release; - } - - if (event.dist === undefined && dist !== undefined) { - event.dist = dist; - } - - if (event.message) { - event.message = truncate(event.message, maxValueLength); - } - - const exception = event.exception && event.exception.values && event.exception.values[0]; - if (exception && exception.value) { - exception.value = truncate(exception.value, maxValueLength); - } - - const request = event.request; - if (request && request.url) { - request.url = truncate(request.url, maxValueLength); - } - } - - /** - * This function adds all used integrations to the SDK info in the event. - * @param event The event that will be filled with all integrations. - */ - protected _applyIntegrationsMetadata(event: Event): void { - const integrationsArray = Object.keys(this._integrations); - if (integrationsArray.length > 0) { - event.sdk = event.sdk || {}; - event.sdk.integrations = [...(event.sdk.integrations || []), ...integrationsArray]; - } + return prepareEvent(options, event, hint, scope); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad1948ce84fb..ccb7169808bd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ export { createTransport } from './transports/base'; export { SDK_VERSION } from './version'; export { getIntegrationsToSetup } from './integration'; export { FunctionToString, InboundFilters } from './integrations'; +export { prepareEvent } from './utils/prepareEvent'; import * as Integrations from './integrations'; diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts new file mode 100644 index 000000000000..1d23d4d89572 --- /dev/null +++ b/packages/core/src/utils/prepareEvent.ts @@ -0,0 +1,192 @@ +import { ClientOptions, Event, EventHint } from '@sentry/types'; +import { dateTimestampInSeconds, normalize, resolvedSyncPromise, truncate, uuid4 } from '@sentry/utils'; + +import { Scope } from '../scope'; + +/** + * Adds common information to events. + * + * The information includes release and environment from `options`, + * breadcrumbs and context (extra, tags and user) from the scope. + * + * Information that is already present in the event is never overwritten. For + * nested objects, such as the context, keys are merged. + * + * Note: This also triggers callbacks for `addGlobalEventProcessor`, but not `beforeSend`. + * + * @param event The original event. + * @param hint May contain additional information about the original exception. + * @param scope A scope containing event metadata. + * @returns A new event with more information. + * @hidden + */ +export function prepareEvent( + options: ClientOptions, + event: Event, + hint: EventHint, + scope?: Scope, +): PromiseLike { + const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = options; + const prepared: Event = { + ...event, + event_id: event.event_id || hint.event_id || uuid4(), + timestamp: event.timestamp || dateTimestampInSeconds(), + }; + + applyClientOptions(prepared, options); + applyIntegrationsMetadata( + prepared, + options.integrations.map(i => i.name), + ); + + // If we have scope given to us, use it as the base for further modifications. + // This allows us to prevent unnecessary copying of data if `captureContext` is not provided. + let finalScope = scope; + if (hint.captureContext) { + finalScope = Scope.clone(finalScope).update(hint.captureContext); + } + + // We prepare the result here with a resolved Event. + let result = resolvedSyncPromise(prepared); + + // This should be the last thing called, since we want that + // {@link Hub.addEventProcessor} gets the finished prepared event. + // + // We need to check for the existence of `finalScope.getAttachments` + // because `getAttachments` can be undefined if users are using an older version + // of `@sentry/core` that does not have the `getAttachments` method. + // See: https://github.com/getsentry/sentry-javascript/issues/5229 + if (finalScope) { + // Collect attachments from the hint and scope + if (finalScope.getAttachments) { + const attachments = [...(hint.attachments || []), ...finalScope.getAttachments()]; + + if (attachments.length) { + hint.attachments = attachments; + } + } + + // In case we have a hub we reassign it. + result = finalScope.applyToEvent(prepared, hint); + } + + return result.then(evt => { + if (typeof normalizeDepth === 'number' && normalizeDepth > 0) { + return normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth); + } + return evt; + }); +} + +/** + * Enhances event using the client configuration. + * It takes care of all "static" values like environment, release and `dist`, + * as well as truncating overly long values. + * @param event event instance to be enhanced + */ +function applyClientOptions(event: Event, options: ClientOptions): void { + const { environment, release, dist, maxValueLength = 250 } = options; + + if (!('environment' in event)) { + event.environment = 'environment' in options ? environment : 'production'; + } + + if (event.release === undefined && release !== undefined) { + event.release = release; + } + + if (event.dist === undefined && dist !== undefined) { + event.dist = dist; + } + + if (event.message) { + event.message = truncate(event.message, maxValueLength); + } + + const exception = event.exception && event.exception.values && event.exception.values[0]; + if (exception && exception.value) { + exception.value = truncate(exception.value, maxValueLength); + } + + const request = event.request; + if (request && request.url) { + request.url = truncate(request.url, maxValueLength); + } +} + +/** + * This function adds all used integrations to the SDK info in the event. + * @param event The event that will be filled with all integrations. + */ +function applyIntegrationsMetadata(event: Event, integrationNames: string[]): void { + if (integrationNames.length > 0) { + event.sdk = event.sdk || {}; + event.sdk.integrations = [...(event.sdk.integrations || []), ...integrationNames]; + } +} + +/** + * Applies `normalize` function on necessary `Event` attributes to make them safe for serialization. + * Normalized keys: + * - `breadcrumbs.data` + * - `user` + * - `contexts` + * - `extra` + * @param event Event + * @returns Normalized event + */ +function normalizeEvent(event: Event | null, depth: number, maxBreadth: number): Event | null { + if (!event) { + return null; + } + + const normalized: Event = { + ...event, + ...(event.breadcrumbs && { + breadcrumbs: event.breadcrumbs.map(b => ({ + ...b, + ...(b.data && { + data: normalize(b.data, depth, maxBreadth), + }), + })), + }), + ...(event.user && { + user: normalize(event.user, depth, maxBreadth), + }), + ...(event.contexts && { + contexts: normalize(event.contexts, depth, maxBreadth), + }), + ...(event.extra && { + extra: normalize(event.extra, depth, maxBreadth), + }), + }; + + // event.contexts.trace stores information about a Transaction. Similarly, + // event.spans[] stores information about child Spans. Given that a + // Transaction is conceptually a Span, normalization should apply to both + // Transactions and Spans consistently. + // For now the decision is to skip normalization of Transactions and Spans, + // so this block overwrites the normalized event to add back the original + // Transaction information prior to normalization. + if (event.contexts && event.contexts.trace && normalized.contexts) { + normalized.contexts.trace = event.contexts.trace; + + // event.contexts.trace.data may contain circular/dangerous data so we need to normalize it + if (event.contexts.trace.data) { + normalized.contexts.trace.data = normalize(event.contexts.trace.data, depth, maxBreadth); + } + } + + // event.spans[].data may contain circular/dangerous data so we need to normalize it + if (event.spans) { + normalized.spans = event.spans.map(span => { + // We cannot use the spread operator here because `toJSON` on `span` is non-enumerable + if (span.data) { + span.data = normalize(span.data, depth, maxBreadth); + } + return span; + }); + } + + return normalized; +}