diff --git a/packages/core/src/utils/debug-ids.ts b/packages/core/src/utils/debug-ids.ts index fd31009ae32d..32396b5bbcac 100644 --- a/packages/core/src/utils/debug-ids.ts +++ b/packages/core/src/utils/debug-ids.ts @@ -1,5 +1,6 @@ import type { DebugImage } from '../types-hoist/debugMeta'; import type { StackParser } from '../types-hoist/stacktrace'; +import { normalizeStackTracePath } from './stacktrace'; import { GLOBAL_OBJ } from './worldwide'; type StackString = string; @@ -10,6 +11,17 @@ let lastSentryKeysCount: number | undefined; let lastNativeKeysCount: number | undefined; let cachedFilenameDebugIds: Record | undefined; +/** + * Clears the cached debug ID mappings. + * Useful for testing or when the global debug ID state changes. + */ +export function clearDebugIdCache(): void { + parsedStackResults = undefined; + lastSentryKeysCount = undefined; + lastNativeKeysCount = undefined; + cachedFilenameDebugIds = undefined; +} + /** * Returns a map of filenames to debug identifiers. * Supports both proprietary _sentryDebugIds and native _debugIds (e.g., from Vercel) formats. @@ -101,12 +113,15 @@ export function getDebugImagesForResources( const images: DebugImage[] = []; for (const path of resource_paths) { - if (path && filenameDebugIdMap[path]) { - images.push({ - type: 'sourcemap', - code_file: path, - debug_id: filenameDebugIdMap[path], - }); + const normalizedPath = normalizeStackTracePath(path); + if (normalizedPath) { + if (filenameDebugIdMap[normalizedPath]) { + images.push({ + type: 'sourcemap', + code_file: path, + debug_id: filenameDebugIdMap[normalizedPath], + }); + } } } diff --git a/packages/core/src/utils/node-stack-trace.ts b/packages/core/src/utils/node-stack-trace.ts index 0cecd3dbf1e9..1132471b0e8f 100644 --- a/packages/core/src/utils/node-stack-trace.ts +++ b/packages/core/src/utils/node-stack-trace.ts @@ -22,7 +22,7 @@ // THE SOFTWARE. import type { StackLineParser, StackLineParserFn } from '../types-hoist/stacktrace'; -import { UNKNOWN_FUNCTION } from './stacktrace'; +import { normalizeStackTracePath, UNKNOWN_FUNCTION } from './stacktrace'; export type GetModuleFn = (filename: string | undefined) => string | undefined; @@ -55,7 +55,6 @@ export function node(getModule?: GetModuleFn): StackLineParserFn { const FULL_MATCH = /at (?:async )?(?:(.+?)\s+\()?(?:(.+):(\d+):(\d+)?|([^)]+))\)?/; const DATA_URI_MATCH = /at (?:async )?(.+?) \(data:(.*?),/; - // eslint-disable-next-line complexity return (line: string) => { const dataUriMatch = line.match(DATA_URI_MATCH); if (dataUriMatch) { @@ -109,14 +108,9 @@ export function node(getModule?: GetModuleFn): StackLineParserFn { functionName = typeName ? `${typeName}.${methodName}` : methodName; } - let filename = lineMatch[2]?.startsWith('file://') ? lineMatch[2].slice(7) : lineMatch[2]; + let filename = normalizeStackTracePath(lineMatch[2]); const isNative = lineMatch[5] === 'native'; - // If it's a Windows path, trim the leading slash so that `/C:/foo` becomes `C:/foo` - if (filename?.match(/\/[A-Z]:/)) { - filename = filename.slice(1); - } - if (!filename && lineMatch[5] && !isNative) { filename = lineMatch[5]; } diff --git a/packages/core/src/utils/stacktrace.ts b/packages/core/src/utils/stacktrace.ts index 6b50caf48b30..16a32ede4e58 100644 --- a/packages/core/src/utils/stacktrace.ts +++ b/packages/core/src/utils/stacktrace.ts @@ -177,3 +177,15 @@ export function getVueInternalName(value: VueViewModel | VNode): string { return isVNode ? '[VueVNode]' : '[VueViewModel]'; } + +/** + * Normalizes stack line paths by removing file:// prefix and leading slashes for Windows paths + */ +export function normalizeStackTracePath(path: string | undefined): string | undefined { + let filename = path?.startsWith('file://') ? path.slice(7) : path; + // If it's a Windows path, trim the leading slash so that `/C:/foo` becomes `C:/foo` + if (filename?.match(/\/[A-Z]:/)) { + filename = filename.slice(1); + } + return filename; +} diff --git a/packages/core/test/lib/utils/debug-ids.test.ts b/packages/core/test/lib/utils/debug-ids.test.ts new file mode 100644 index 000000000000..45ff58c82565 --- /dev/null +++ b/packages/core/test/lib/utils/debug-ids.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { nodeStackLineParser } from '../../../src'; +import { clearDebugIdCache, getDebugImagesForResources, getFilenameToDebugIdMap } from '../../../src/utils/debug-ids'; +import { createStackParser } from '../../../src/utils/stacktrace'; + +const nodeStackParser = createStackParser(nodeStackLineParser()); + +describe('getDebugImagesForResources', () => { + beforeEach(() => { + // Clear any existing debug ID maps + delete (globalThis as any)._sentryDebugIds; + delete (globalThis as any)._debugIds; + clearDebugIdCache(); + }); + + it('should return debug images for resources without file:// prefix', () => { + // Setup debug IDs + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'debug-id-123', + }; + + const resources = ['/var/task/index.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(1); + expect(images[0]).toEqual({ + type: 'sourcemap', + code_file: '/var/task/index.js', + debug_id: 'debug-id-123', + }); + }); + + it('should return debug images for resources with file:// prefix', () => { + // Setup debug IDs - the stack parser strips file:// when parsing + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'debug-id-123', + }; + + // V8 profiler returns resources WITH file:// prefix + const resources = ['file:///var/task/index.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(1); + expect(images[0]).toEqual({ + type: 'sourcemap', + code_file: 'file:///var/task/index.js', + debug_id: 'debug-id-123', + }); + }); + + it('should handle mixed resources with and without file:// prefix', () => { + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'debug-id-123', + 'at anotherFunction (/var/task/utils.js:10:5)': 'debug-id-456', + }; + + const resources = ['file:///var/task/index.js', '/var/task/utils.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(2); + expect(images[0]).toEqual({ + type: 'sourcemap', + code_file: 'file:///var/task/index.js', + debug_id: 'debug-id-123', + }); + expect(images[1]).toEqual({ + type: 'sourcemap', + code_file: '/var/task/utils.js', + debug_id: 'debug-id-456', + }); + }); + + it('should return empty array when no debug IDs match', () => { + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'debug-id-123', + }; + + const resources = ['file:///var/task/other.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(0); + }); + + it('should return empty array when no debug IDs are registered', () => { + const resources = ['file:///var/task/index.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(0); + }); + + it('should handle empty resource paths array', () => { + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'debug-id-123', + }; + + const resources: string[] = []; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(0); + }); + + it('should handle Windows paths with file:// prefix', () => { + // Stack parser normalizes Windows paths: file:///C:/foo.js -> C:/foo.js + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (C:/Users/dev/project/index.js:1:1)': 'debug-id-win-123', + }; + + // V8 profiler returns Windows paths with file:// prefix + const resources = ['file:///C:/Users/dev/project/index.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(1); + expect(images[0]).toEqual({ + type: 'sourcemap', + code_file: 'file:///C:/Users/dev/project/index.js', + debug_id: 'debug-id-win-123', + }); + }); + + it('should handle Windows paths without file:// prefix', () => { + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (C:/Users/dev/project/index.js:1:1)': 'debug-id-win-123', + }; + + const resources = ['C:/Users/dev/project/index.js']; + const images = getDebugImagesForResources(nodeStackParser, resources); + + expect(images).toHaveLength(1); + expect(images[0]).toEqual({ + type: 'sourcemap', + code_file: 'C:/Users/dev/project/index.js', + debug_id: 'debug-id-win-123', + }); + }); +}); + +describe('getFilenameToDebugIdMap', () => { + beforeEach(() => { + delete (globalThis as any)._sentryDebugIds; + delete (globalThis as any)._debugIds; + clearDebugIdCache(); + }); + + it('should return empty object when no debug IDs are registered', () => { + const map = getFilenameToDebugIdMap(nodeStackParser); + expect(map).toEqual({}); + }); + + it('should build map from _sentryDebugIds', () => { + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'debug-id-123', + 'at anotherFunction (/var/task/utils.js:10:5)': 'debug-id-456', + }; + + const map = getFilenameToDebugIdMap(nodeStackParser); + + expect(map).toEqual({ + '/var/task/index.js': 'debug-id-123', + '/var/task/utils.js': 'debug-id-456', + }); + }); + + it('should build map from native _debugIds', () => { + (globalThis as any)._debugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'native-debug-id-123', + }; + + const map = getFilenameToDebugIdMap(nodeStackParser); + + expect(map).toEqual({ + '/var/task/index.js': 'native-debug-id-123', + }); + }); + + it('should prioritize native _debugIds over _sentryDebugIds', () => { + (globalThis as any)._sentryDebugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'sentry-debug-id', + }; + (globalThis as any)._debugIds = { + 'at mockFunction (/var/task/index.js:1:1)': 'native-debug-id', + }; + + const map = getFilenameToDebugIdMap(nodeStackParser); + + expect(map['/var/task/index.js']).toBe('native-debug-id'); + }); +});