diff --git a/src/__test__/__snapshots__/applySourceMapsToEvents.test.ts.snap b/src/__test__/__snapshots__/applySourceMapsToEvents.test.ts.snap new file mode 100644 index 0000000..807f5bf --- /dev/null +++ b/src/__test__/__snapshots__/applySourceMapsToEvents.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`applySourceMapsToEvents dev profiles should throw an error if args are not populated 1`] = `"Source maps could not be derived for an event at 1000 and with stackFrame ID undefined"`; diff --git a/src/__test__/applySourceMapsToEvents.test.ts b/src/__test__/applySourceMapsToEvents.test.ts new file mode 100644 index 0000000..1870228 --- /dev/null +++ b/src/__test__/applySourceMapsToEvents.test.ts @@ -0,0 +1,265 @@ +import { SourceMap } from '../types/SourceMap'; +import { SourceMapConsumer } from 'source-map'; +import { DurationEvent } from '../types/EventInterfaces'; +import { EventsPhase } from '../types/Phases'; +import applySourceMapsToEvents from '../profiler/applySourceMapsToEvents'; + +const mockOriginalPositionFor = jest.fn(); + +jest.mock('source-map', () => ({ + SourceMapConsumer: jest.fn().mockImplementation(() => ({ + originalPositionFor: mockOriginalPositionFor.mockReturnValue({ + source: 'source.js', + line: 1, + column: 10, + name: 'console.log', + }), + destroy: jest.fn(), + })), +})); + +describe('applySourceMapsToEvents', () => { + let defaultEvents: DurationEvent[] = []; + + const mockSourceMap: SourceMap = { + version: '3', + sources: ['source.js'], + sourceContent: ['console.log("Hello, World!");'], + x_facebook_sources: null, + names: ['console', 'log', 'Hello, World!'], + mappings: 'AAAAA,QAAAC,IAAA', + }; + + beforeEach(() => { + ((SourceMapConsumer as unknown) as jest.Mock).mockImplementation(() => ({ + originalPositionFor: mockOriginalPositionFor.mockReturnValue({ + source: 'source.js', + line: 5, + column: 10, + name: 'console.log', + }), + destroy: jest.fn(), + })); + mockOriginalPositionFor.mockClear(); + + // applySourceMapsToEvents modifies the events array, so we need to reset it before each test + defaultEvents = [ + { + ph: EventsPhase.DURATION_EVENTS_BEGIN, + args: { + line: 1, + column: 1, + }, + cat: 'default-category', + name: 'event1', + ts: 1000, + pid: 1, + tid: 1, + }, + { + ph: EventsPhase.DURATION_EVENTS_END, + args: { + line: 1, + column: 1, + }, + cat: 'default-category', + name: 'event1', + ts: 1010, + pid: 1, + tid: 1, + }, + ]; + }); + + describe('dev profiles', () => { + it('should enhance events with source map information', async () => { + const indexBundleFileName = 'index.bundle'; + const enhancedEvents = await applySourceMapsToEvents( + mockSourceMap, + defaultEvents, + indexBundleFileName + ); + + expect(enhancedEvents).toEqual([ + expect.objectContaining({ + args: { + url: 'source.js', + line: 5, + column: 10, + params: 'console.log', + allocatedCategory: 'default-category', + allocatedName: 'event1', + node_module: 'default-category', + }, + }), + expect.objectContaining({ + args: { + url: 'source.js', + line: 5, + column: 10, + params: 'console.log', + allocatedCategory: 'default-category', + allocatedName: 'event1', + node_module: 'default-category', + }, + }), + ]); + }); + + it('should throw an error if args are not populated', async () => { + const eventsWithMissingArgs: DurationEvent[] = [ + { + ...defaultEvents[0], + args: undefined as any, + }, + ]; + + await expect( + applySourceMapsToEvents(mockSourceMap, eventsWithMissingArgs, undefined) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + + describe('production profiles', () => { + it('should enhance events with source map information', async () => { + const productionEvents: DurationEvent[] = [ + { + ph: EventsPhase.DURATION_EVENTS_BEGIN, + args: { + funcVirtAddr: '5', + offset: '7', + }, + cat: 'default-category', + name: 'event1', + ts: 1000, + pid: 1, + tid: 1, + }, + { + ph: EventsPhase.DURATION_EVENTS_END, + args: { + funcVirtAddr: '5', + offset: '7', + }, + cat: 'default-category', + name: 'event1', + ts: 1010, + pid: 1, + tid: 1, + }, + ]; + + const indexBundleFileName = 'index.bundle'; + const enhancedEvents = await applySourceMapsToEvents( + mockSourceMap, + productionEvents, + indexBundleFileName + ); + + expect(mockOriginalPositionFor).toHaveBeenCalledTimes(2); + expect(mockOriginalPositionFor).toHaveBeenCalledWith({ + line: 1, + column: 13, + }); + + expect(enhancedEvents).toEqual([ + expect.objectContaining({ + args: { + url: 'source.js', + line: 5, + column: 10, + funcVirtAddr: '5', + offset: '7', + params: 'console.log', + allocatedCategory: 'default-category', + allocatedName: 'event1', + node_module: 'default-category', + }, + }), + expect.objectContaining({ + args: { + url: 'source.js', + line: 5, + column: 10, + funcVirtAddr: '5', + offset: '7', + params: 'console.log', + allocatedCategory: 'default-category', + allocatedName: 'event1', + node_module: 'default-category', + }, + }), + ]); + }); + }); + + describe('setting event category', () => { + it.each([ + { + nodeModuleName: 'react-native', + expectedCategory: 'react-native-internals', + }, + { nodeModuleName: 'react', expectedCategory: 'react-native-internals' }, + { nodeModuleName: 'metro', expectedCategory: 'react-native-internals' }, + { + nodeModuleName: 'other-module', + expectedCategory: 'other_node_modules', + }, + ])( + 'should correctly improve categories for node modules', + async ({ nodeModuleName, expectedCategory }) => { + ((SourceMapConsumer as unknown) as jest.Mock).mockImplementation( + () => ({ + originalPositionFor: jest.fn().mockReturnValue({ + source: `/workdir/node_modules/${nodeModuleName}/index.js`, + line: 1, + column: 10, + name: 'console.log', + }), + destroy: jest.fn(), + }) + ); + + const enhancedEvents = await applySourceMapsToEvents( + mockSourceMap, + defaultEvents, + undefined + ); + + expect(enhancedEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cat: expectedCategory, + }), + ]) + ); + } + ); + + it('should default to the source as the category', async () => { + ((SourceMapConsumer as unknown) as jest.Mock).mockImplementation(() => ({ + originalPositionFor: jest.fn().mockReturnValue({ + source: `asdfasd.js`, + line: 1, + column: 10, + name: 'console.log', + }), + destroy: jest.fn(), + })); + + const enhancedEvents = await applySourceMapsToEvents( + mockSourceMap, + defaultEvents, + undefined + ); + + expect(enhancedEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cat: 'default-category', + }), + ]) + ); + }); + }); +}); diff --git a/src/profiler/applySourceMapsToEvents.ts b/src/profiler/applySourceMapsToEvents.ts index ae94de5..e507bf3 100644 --- a/src/profiler/applySourceMapsToEvents.ts +++ b/src/profiler/applySourceMapsToEvents.ts @@ -74,9 +74,23 @@ const applySourceMapsToEvents = async ( const consumer = await new SourceMapConsumer(rawSourceMap); const events = chromeEvents.map((event: DurationEvent) => { if (event.args) { + let line = Number(event.args.line); + let column = Number(event.args.column); + + // For production profiles we construct line + column from the funcVirtAddr and offset + // See https://github.com/facebook/metro/blob/139f58c1eeb7b61318f6307d5be650e5484ea1c5/packages/metro-symbolicate/src/Symbolication.js#L301 + if ( + event.args.funcVirtAddr !== undefined && + event.args.offset !== undefined + ) { + line = 1; + column = + Number(event.args.funcVirtAddr) + Number(event.args.offset) + 1; + } + const sm = consumer.originalPositionFor({ - line: Number(event.args.line), - column: Number(event.args.column), + line, + column, }); /** * The categories can help us better visualise the profile if we modify the categories. @@ -87,6 +101,7 @@ const applySourceMapsToEvents = async ( event.cat!, sm.source ); + event.cat = improveCategories(nodeModuleNameIfAvailable, event.cat!); event.args = { ...event.args,