diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts new file mode 100644 index 000000000000..d1ebac6e90e5 --- /dev/null +++ b/packages/core/src/metadata.ts @@ -0,0 +1,97 @@ +import type { Event, StackParser } from '@sentry/types'; +import { GLOBAL_OBJ } from '@sentry/utils'; + +/** Keys are source filename/url, values are metadata objects. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const filenameMetadataMap = new Map(); +/** Set of stack strings that have already been parsed. */ +const parsedStacks = new Set(); + +function ensureMetadataStacksAreParsed(parser: StackParser): void { + if (!GLOBAL_OBJ._sentryModuleMetadata) { + return; + } + + for (const stack of Object.keys(GLOBAL_OBJ._sentryModuleMetadata)) { + const metadata = GLOBAL_OBJ._sentryModuleMetadata[stack]; + + if (parsedStacks.has(stack)) { + continue; + } + + // Ensure this stack doesn't get parsed again + parsedStacks.add(stack); + + const frames = parser(stack); + + // Go through the frames starting from the top of the stack and find the first one with a filename + for (const frame of frames.reverse()) { + if (frame.filename) { + // Save the metadata for this filename + filenameMetadataMap.set(frame.filename, metadata); + break; + } + } + } +} + +/** + * Retrieve metadata for a specific JavaScript file URL. + * + * Metadata is injected by the Sentry bundler plugins using the `_experiments.moduleMetadata` config option. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getMetadataForUrl(parser: StackParser, filename: string): any | undefined { + ensureMetadataStacksAreParsed(parser); + return filenameMetadataMap.get(filename); +} + +/** + * Adds metadata to stack frames. + * + * Metadata is injected by the Sentry bundler plugins using the `_experiments.moduleMetadata` config option. + */ +export function addMetadataToStackFrames(parser: StackParser, event: Event): void { + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + event.exception!.values!.forEach(exception => { + if (!exception.stacktrace) { + return; + } + + for (const frame of exception.stacktrace.frames || []) { + if (!frame.filename) { + continue; + } + + const metadata = getMetadataForUrl(parser, frame.filename); + + if (metadata) { + frame.module_metadata = metadata; + } + } + }); + } catch (_) { + // To save bundle size we're just try catching here instead of checking for the existence of all the different objects. + } +} + +/** + * Strips metadata from stack frames. + */ +export function stripMetadataFromStackFrames(event: Event): void { + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + event.exception!.values!.forEach(exception => { + if (!exception.stacktrace) { + return; + } + + for (const frame of exception.stacktrace.frames || []) { + delete frame.module_metadata; + } + }); + } catch (_) { + // To save bundle size we're just try catching here instead of checking for the existence of all the different objects. + } +} diff --git a/packages/core/test/lib/metadata.test.ts b/packages/core/test/lib/metadata.test.ts new file mode 100644 index 000000000000..64b3fd7310ad --- /dev/null +++ b/packages/core/test/lib/metadata.test.ts @@ -0,0 +1,97 @@ +import type { Event } from '@sentry/types'; +import { createStackParser, GLOBAL_OBJ, nodeStackLineParser } from '@sentry/utils'; + +import { addMetadataToStackFrames, getMetadataForUrl, stripMetadataFromStackFrames } from '../../src/metadata'; + +const parser = createStackParser(nodeStackLineParser()); + +const stack = new Error().stack || ''; + +const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: '', + function: 'new Promise', + }, + { + filename: '/tmp/utils.js', + function: 'Promise.then.completed', + lineno: 391, + colno: 28, + }, + { + filename: __filename, + function: 'Object.', + lineno: 9, + colno: 19, + }, + ], + }, + }, + ], + }, +}; + +describe('Metadata', () => { + beforeEach(() => { + GLOBAL_OBJ._sentryModuleMetadata = GLOBAL_OBJ._sentryModuleMetadata || {}; + GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' }; + }); + + it('is parsed', () => { + const metadata = getMetadataForUrl(parser, __filename); + + expect(metadata).toEqual({ team: 'frontend' }); + }); + + it('is added and stripped from stack frames', () => { + addMetadataToStackFrames(parser, event); + + expect(event.exception?.values?.[0].stacktrace?.frames).toEqual([ + { + filename: '', + function: 'new Promise', + }, + { + filename: '/tmp/utils.js', + function: 'Promise.then.completed', + lineno: 391, + colno: 28, + }, + { + filename: __filename, + function: 'Object.', + lineno: 9, + colno: 19, + module_metadata: { + team: 'frontend', + }, + }, + ]); + + stripMetadataFromStackFrames(event); + + expect(event.exception?.values?.[0].stacktrace?.frames).toEqual([ + { + filename: '', + function: 'new Promise', + }, + { + filename: '/tmp/utils.js', + function: 'Promise.then.completed', + lineno: 391, + colno: 28, + }, + { + filename: __filename, + function: 'Object.', + lineno: 9, + colno: 19, + }, + ]); + }); +}); diff --git a/packages/types/src/stackframe.ts b/packages/types/src/stackframe.ts index 7a8058a1792c..4f99f1ba3595 100644 --- a/packages/types/src/stackframe.ts +++ b/packages/types/src/stackframe.ts @@ -15,4 +15,5 @@ export interface StackFrame { addr_mode?: string; vars?: { [key: string]: any }; debug_id?: string; + module_metadata?: any; } diff --git a/packages/utils/src/worldwide.ts b/packages/utils/src/worldwide.ts index 37a6deb30851..12e098bf3bc7 100644 --- a/packages/utils/src/worldwide.ts +++ b/packages/utils/src/worldwide.ts @@ -55,6 +55,12 @@ export interface InternalGlobal { [key: string]: Function; }; }; + /** + * Raw module metadata that is injected by bundler plugins. + * + * Keys are `error.stack` strings, values are the metadata. + */ + _sentryModuleMetadata?: Record; } // The code below for 'isGlobalObj' and 'GLOBAL_OBJ' was copied from core-js before modification