diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 0a69fc44d858..9dbe7f977d7e 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -34,6 +34,7 @@ export { spanStatusfromHttpCode, trace, makeMultiplexedTransport, + ModuleMetadata, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core'; export type { Span } from '@sentry/types'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db9d21c2f2e8..9ff61f05cdb3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,7 +46,7 @@ export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasTracingEnabled } from './utils/hasTracingEnabled'; export { DEFAULT_ENVIRONMENT } from './constants'; - +export { ModuleMetadata } from './integrations/metadata'; import * as Integrations from './integrations'; export { Integrations }; diff --git a/packages/core/src/integrations/metadata.ts b/packages/core/src/integrations/metadata.ts new file mode 100644 index 000000000000..05af1d88ebe9 --- /dev/null +++ b/packages/core/src/integrations/metadata.ts @@ -0,0 +1,57 @@ +import type { EventItem, EventProcessor, Hub, Integration } from '@sentry/types'; +import { forEachEnvelopeItem } from '@sentry/utils'; + +import { addMetadataToStackFrames, stripMetadataFromStackFrames } from '../metadata'; + +/** + * Adds module metadata to stack frames. + * + * Metadata can be injected by the Sentry bundler plugins using the `_experiments.moduleMetadata` config option. + * + * When this integration is added, the metadata passed to the bundler plugin is added to the stack frames of all events + * under the `module_metadata` property. This can be used to help in tagging or routing of events from different teams + * our sources + */ +export class ModuleMetadata implements Integration { + /* + * @inheritDoc + */ + public static id: string = 'ModuleMetadata'; + + /** + * @inheritDoc + */ + public name: string = ModuleMetadata.id; + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (processor: EventProcessor) => void, getCurrentHub: () => Hub): void { + const client = getCurrentHub().getClient(); + + if (!client || typeof client.on !== 'function') { + return; + } + + // We need to strip metadata from stack frames before sending them to Sentry since these are client side only. + client.on('beforeEnvelope', envelope => { + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'event') { + const event = Array.isArray(item) ? (item as EventItem)[1] : undefined; + + if (event) { + stripMetadataFromStackFrames(event); + item[1] = event; + } + } + }); + }); + + const stackParser = client.getOptions().stackParser; + + addGlobalEventProcessor(event => { + addMetadataToStackFrames(stackParser, event); + return event; + }); + } +} diff --git a/packages/core/test/lib/integrations/metadata.test.ts b/packages/core/test/lib/integrations/metadata.test.ts new file mode 100644 index 000000000000..464da2d97fbb --- /dev/null +++ b/packages/core/test/lib/integrations/metadata.test.ts @@ -0,0 +1,66 @@ +import type { Event } from '@sentry/types'; +import { createStackParser, GLOBAL_OBJ, nodeStackLineParser, parseEnvelope } from '@sentry/utils'; +import { TextDecoder, TextEncoder } from 'util'; + +import { createTransport, getCurrentHub, ModuleMetadata } from '../../../src'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const stackParser = createStackParser(nodeStackLineParser()); + +const stack = new Error().stack || ''; + +describe('ModuleMetadata integration', () => { + beforeEach(() => { + TestClient.sendEventCalled = undefined; + TestClient.instance = undefined; + + GLOBAL_OBJ._sentryModuleMetadata = GLOBAL_OBJ._sentryModuleMetadata || {}; + GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Adds and removes metadata from stack frames', done => { + const options = getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + stackParser, + integrations: [new ModuleMetadata()], + beforeSend: (event, _hint) => { + // copy the frames since reverse in in-place + const lastFrame = [...(event.exception?.values?.[0].stacktrace?.frames || [])].reverse()[0]; + // Ensure module_metadata is populated in beforeSend callback + expect(lastFrame?.module_metadata).toEqual({ team: 'frontend' }); + return event; + }, + transport: () => + createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, async req => { + const [, items] = parseEnvelope(req.body, new TextEncoder(), new TextDecoder()); + + expect(items[0][1]).toBeDefined(); + const event = items[0][1] as Event; + const error = event.exception?.values?.[0]; + + // Ensure we're looking at the same error we threw + expect(error?.value).toEqual('Some error'); + + const lastFrame = [...(error?.stacktrace?.frames || [])].reverse()[0]; + // Ensure the last frame is in fact for this file + expect(lastFrame?.filename).toEqual(__filename); + + // Ensure module_metadata has been stripped from the event + expect(lastFrame?.module_metadata).toBeUndefined(); + + done(); + return {}; + }), + }); + + const client = new TestClient(options); + const hub = getCurrentHub(); + hub.bindClient(client); + hub.captureException(new Error('Some error')); + }); +}); diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts index 7ca980189e19..f7d8830a1402 100644 --- a/packages/core/test/mocks/client.ts +++ b/packages/core/test/mocks/client.ts @@ -54,7 +54,7 @@ export class TestClient extends BaseClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types public eventFromException(exception: any): PromiseLike { - return resolvedSyncPromise({ + const event: Event = { exception: { values: [ { @@ -65,7 +65,14 @@ export class TestClient extends BaseClient { }, ], }, - }); + }; + + const frames = this._options.stackParser(exception.stack || '', 1); + if (frames.length && event?.exception?.values?.[0]) { + event.exception.values[0] = { ...event.exception.values[0], stacktrace: { frames } }; + } + + return resolvedSyncPromise(event); } public eventFromMessage(