Skip to content

Commit fe5f9f0

Browse files
authored
Add source map symbolication and source view support (#6018)
The bugzilla part of this PR: https://bugzilla.mozilla.org/show_bug.cgi?id=2035493 This requires a Firefox that has patches applied in [bug 2035493](https://bugzilla.mozilla.org/show_bug.cgi?id=2035493). And also it requires the "JavaScript Sources" features to be on. It adds a pipeline that maps compiled/bundled JS frame positions back to their original source files using source maps fetched from the browser after profile load, similarly to our native symbolication step. And it adds a way to see the source mapped source contents. After this PR, the call tree, flame graph tooltip, source view, and line timings show original TS/JS positions and function names for frames recorded in minified bundles. I split the work in multiple patches so it's easier to review, but admittedly it's a lot of changes. The initial commits in the PR don't change the behavior until the commit that wires everything up and updates the visualization. Example STR: - Use a Firefox that includes my patches. And enable the "JavaScript Sources" feature in about:profiling. - Start the profiler - Load an example website that has JS source maps, for example the Firefox Profiler frontend - Capture a profile - Wait until symbolication and source map resolution is complete You should then see the symbolicated functions on the frames that are coming from Firefox Profiler source code. (Note that the react code will still be minified as it doesn't have source maps). You should also be able to double click on a JS function function to be able to see the source mapped JS source.
2 parents f6eedaf + 3d1ca29 commit fe5f9f0

80 files changed

Lines changed: 40828 additions & 6953 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs-developer/CHANGELOG-formats.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ Note that this is not an exhaustive list. Processed profile format upgraders can
66

77
## Processed profile format
88

9+
### Version 64
10+
11+
A new `SourceLocationTable` has been added to `profile.shared.sourceLocationTable`. It holds the original (pre-compilation) source positions produced by source map symbolication, paired with the generated `line`/`column` already on `FrameTable`.
12+
13+
- `source: IndexIntoSourceTable[]`: source file index. Set independently for func entries (the function's definition file) and frame entries (the execution point's file).
14+
- `line: number[]`: 1-based line number
15+
- `column: number[]`: 1-based column number
16+
17+
Two new columns were added that index into this table:
18+
19+
- `FrameTable.originalLocation: Array<IndexIntoSourceLocationTable | null>`: the original execution point for the frame
20+
- `FuncTable.originalLocation: Array<IndexIntoSourceLocationTable | null>`: the original definition site for the function
21+
22+
A new `content: Array<string | null>` column was added to `SourceTable`.
23+
924
### Version 63
1025

1126
A new `tooltipRows` field was added to `CounterDisplayConfig`.

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const browserEnvConfig = {
3030

3131
globals: {
3232
AVAILABLE_STAGING_LOCALES: null,
33+
SOURCE_MAP_WORKER_PATH: 'src/test/fixtures/source-map.worker.stub.js',
3334
},
3435

3536
snapshotFormat: {

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@fluent/langneg": "^0.7.0",
7777
"@fluent/react": "^0.15.2",
7878
"@lezer/highlight": "^1.2.3",
79+
"@lezer/javascript": "^1.5.4",
7980
"@streamparser/json": "^0.0.22",
8081
"@tgwf/co2": "^0.18.0",
8182
"array-move": "^3.0.1",
@@ -109,6 +110,8 @@
109110
"redux-logger": "^3.0.6",
110111
"redux-thunk": "^3.1.0",
111112
"reselect": "^4.1.8",
113+
"source-map": "^0.7.6",
114+
"url": "^0.11.4",
112115
"valibot": "^1.4.1",
113116
"workbox-window": "^7.4.1"
114117
},

scripts/build-profiler-cli.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ const profilerCliConfig = {
2323
define: {
2424
__BUILD_HASH__: JSON.stringify(BUILD_HASH),
2525
__VERSION__: JSON.stringify(version),
26+
// SOURCE_MAP_WORKER_PATH is injected by the browser build. The CLI doesn't
27+
// use source map workers but the shared code references this constant.
28+
SOURCE_MAP_WORKER_PATH: JSON.stringify('/source-map.worker.js'),
2629
},
2730
external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'],
2831
};

scripts/build.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,32 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
import esbuild from 'esbuild';
55

6-
import { mainBundleConfig } from './lib/esbuild-configs.mjs';
6+
import {
7+
mainBundleConfig,
8+
sourceMapWorkerConfig,
9+
getSourceMapWorkerPath,
10+
} from './lib/esbuild-configs.mjs';
711
import { cleanDist, saveMetafile } from './lib/build-utils.mjs';
812

913
async function build() {
1014
cleanDist();
11-
const buildResult = await esbuild.build(mainBundleConfig);
15+
16+
// Build the worker first so we can read its output path from the metafile
17+
// and inject it into the main bundle via SOURCE_MAP_WORKER_PATH.
18+
const workerResult = await esbuild.build(sourceMapWorkerConfig);
19+
20+
const buildResult = await esbuild.build({
21+
...mainBundleConfig,
22+
define: {
23+
...mainBundleConfig.define,
24+
SOURCE_MAP_WORKER_PATH: JSON.stringify(
25+
getSourceMapWorkerPath(workerResult.metafile)
26+
),
27+
},
28+
});
29+
1230
saveMetafile(buildResult);
13-
console.log('✅ Main browser build completed');
31+
console.log('✅ Main browser build and source map worker completed');
1432
}
1533

1634
build().catch(console.error);

scripts/lib/dev-server.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export async function startDevServer(buildConfig, options = {}) {
5656
fallback = 'index.html',
5757
onServerStart,
5858
cleanDist = true,
59+
extraWatchConfigs = [],
5960
} = options;
6061

6162
// Clean dist directory first
@@ -77,6 +78,12 @@ export async function startDevServer(buildConfig, options = {}) {
7778
// Start watching for changes
7879
await buildContext.watch();
7980

81+
// Watch extra configs (no serving needed, just watch for rebuilds)
82+
const extraContexts = await Promise.all(
83+
extraWatchConfigs.map((config) => esbuild.context(config))
84+
);
85+
await Promise.all(extraContexts.map((ctx) => ctx.watch()));
86+
8087
// Create HTTP server
8188
const server = http.createServer((req, res) => {
8289
// Validate Host header
@@ -135,7 +142,9 @@ export async function startDevServer(buildConfig, options = {}) {
135142
isShuttingDown = true;
136143

137144
console.log('\nShutting down...');
138-
await buildContext.dispose();
145+
await Promise.all(
146+
[buildContext, ...extraContexts].map((ctx) => ctx.dispose())
147+
);
139148
server.close();
140149
process.exit(0);
141150
});

scripts/lib/esbuild-configs.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export const mainBundleConfig = {
8181
: 'undefined',
8282
// no need to define NODE_ENV:
8383
// esbuild automatically defines NODE_ENV based on the value for "minify"
84+
// In dev, the worker is not hashed so the path is predictable.
85+
// In production, build.mjs overrides this after building the worker first.
86+
SOURCE_MAP_WORKER_PATH: JSON.stringify('/source-map.worker.js'),
8487
},
8588
external: ['zlib'],
8689
plugins: [
@@ -98,6 +101,10 @@ export const mainBundleConfig = {
98101
{ from: ['res/img/favicon.png'], to: ['dist/res/img'] },
99102
{ from: ['docs-user/**/*'], to: ['dist/docs'] },
100103
{ from: ['locales/**/*'], to: ['dist/locales'] },
104+
{
105+
from: ['node_modules/source-map/lib/mappings.wasm'],
106+
to: ['dist'],
107+
},
101108
],
102109
}),
103110
generateHtmlPlugin({
@@ -108,6 +115,35 @@ export const mainBundleConfig = {
108115
],
109116
};
110117

118+
// Source map worker bundle configuration.
119+
// Built as a standalone IIFE so that npm dependencies (lezer, source-map) are
120+
// bundled into a single file that can be loaded as a Web Worker without needing
121+
// ES module support. In production the output filename includes a content hash
122+
// (e.g. source-map-ABCD1234.worker.js). The path is then injected into the main
123+
// bundle via the SOURCE_MAP_WORKER_PATH define. In dev there is no hash since the
124+
// dev server always serves fresh content and the define can't be updated mid-watch.
125+
export const sourceMapWorkerConfig = {
126+
...baseConfig,
127+
entryPoints: ['src/profile-logic/source-map.worker.ts'],
128+
outdir: 'dist',
129+
format: 'iife',
130+
platform: 'browser',
131+
target: browserslistToEsbuild(),
132+
sourcemap: true,
133+
splitting: false,
134+
entryNames: isProduction ? '[name]-[hash]' : '[name]',
135+
metafile: true,
136+
plugins: [wasmLoader()],
137+
};
138+
139+
export function getSourceMapWorkerPath(metafile) {
140+
const [entryPoint] = sourceMapWorkerConfig.entryPoints;
141+
const [outputPath] = Object.entries(metafile.outputs).find(
142+
([, output]) => output.entryPoint === entryPoint
143+
);
144+
return '/' + path.basename(outputPath);
145+
}
146+
111147
// Photon styling build configuration
112148
const photonTemplateHTML = fs.readFileSync(
113149
path.join(projectRoot, 'res', 'photon', 'index.html'),

scripts/run-dev-server.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
import path from 'path';
5-
import { mainBundleConfig } from './lib/esbuild-configs.mjs';
5+
import {
6+
mainBundleConfig,
7+
sourceMapWorkerConfig,
8+
} from './lib/esbuild-configs.mjs';
69
import { startDevServer } from './lib/dev-server.mjs';
710
import { serveAndOpenProfile } from './lib/profile-server.mjs';
811
import yargs from 'yargs';
@@ -22,6 +25,7 @@ startDevServer(mainBundleConfig, {
2225
host,
2326
distDir: 'dist',
2427
cleanDist: true,
28+
extraWatchConfigs: [sourceMapWorkerConfig],
2529
onServerStart: (profilerUrl) => {
2630
const barAscii =
2731
'------------------------------------------------------------------------------------------';

src/actions/receive-profile.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ import {
7676
determineTimelineType,
7777
hasUsefulSamples,
7878
} from 'firefox-profiler/profile-logic/profile-data';
79+
import { doSourceMapSymbolication } from './source-map-symbolication';
7980

81+
import type { RawSourceMap } from 'source-map';
8082
import type {
8183
RequestedLib,
8284
ImplementationFilter,
@@ -89,6 +91,7 @@ import type {
8991
TabID,
9092
PageList,
9193
MixedObject,
94+
IndexIntoSourceTable,
9295
} from 'firefox-profiler/types';
9396

9497
import type { SymbolicationStepInfo } from '../profile-logic/symbolication';
@@ -245,7 +248,28 @@ export function finalizeProfileView(
245248
}
246249
}
247250

248-
await Promise.all([faviconsPromise, symbolicationPromise]);
251+
// Fetch source maps for all JS sources with a sourceMapURL, then run the
252+
// source-map worker. Runs fully in parallel with native symbolication:
253+
// native only touches funcs/frames belonging to library resources (JS
254+
// funcs aren't in those sets), and the JS apply step reads current
255+
// shared state at dispatch time so it composes with whatever native
256+
// has committed by then. Requires WebChannel version 7+.
257+
let sourceMapSymbolicationPromise: Promise<void> | null = null;
258+
if (browserConnection !== null && browserConnection.supportsGetSourceMap) {
259+
sourceMapSymbolicationPromise = doResolveSourceMaps(
260+
profile,
261+
browserConnection,
262+
dispatch
263+
).then(({ resolvedSourceMaps, compiledSources }) =>
264+
dispatch(doSourceMapSymbolication(resolvedSourceMaps, compiledSources))
265+
);
266+
}
267+
268+
await Promise.all([
269+
faviconsPromise,
270+
symbolicationPromise,
271+
sourceMapSymbolicationPromise,
272+
]);
249273
};
250274
}
251275

@@ -811,6 +835,85 @@ export async function doSymbolicateProfile(
811835
dispatch(doneSymbolicating());
812836
}
813837

838+
/**
839+
* Resolve JS source maps for every source in the profile that has both a
840+
* sourceMapURL and a UUID. Fetches source maps via the browser WebChannel.
841+
*
842+
* Also fetches the compiled source text which is required by the scope-tree
843+
* name resolution in symbolicateWithSourceMaps.
844+
*/
845+
async function doResolveSourceMaps(
846+
profile: Profile,
847+
browserConnection: BrowserConnection,
848+
dispatch: Dispatch
849+
): Promise<{
850+
resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap>;
851+
compiledSources: Map<IndexIntoSourceTable, string>;
852+
}> {
853+
const { sources, stringArray } = profile.shared;
854+
855+
// Collect every source with a sourceMapURL and a UUID. Only UUID-bearing
856+
// sources can be fetched via the browser WebChannel.
857+
const sourceIndexesWithSourceMaps = new Set<IndexIntoSourceTable>();
858+
for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) {
859+
if (
860+
sources.sourceMapURL[sourceIndex] !== null &&
861+
sources.sourceMapURL[sourceIndex] !== undefined &&
862+
typeof sources.id[sourceIndex] === 'string'
863+
) {
864+
sourceIndexesWithSourceMaps.add(sourceIndex);
865+
}
866+
}
867+
868+
if (sourceIndexesWithSourceMaps.size === 0) {
869+
return { resolvedSourceMaps: new Map(), compiledSources: new Map() };
870+
}
871+
872+
// Fetch source maps and compiled sources in parallel, ignoring individual failures.
873+
const resolvedSourceMaps: Map<IndexIntoSourceTable, RawSourceMap> = new Map();
874+
const compiledSources: Map<IndexIntoSourceTable, string> = new Map();
875+
876+
dispatch({ type: 'START_SOURCE_MAP_FETCHING' });
877+
try {
878+
await Promise.all(
879+
Array.from(sourceIndexesWithSourceMaps).map(async (sourceIndex) => {
880+
const filename = stringArray[sources.filename[sourceIndex]];
881+
// sourceId is guaranteed non-null by the filter above.
882+
const sourceId = sources.id[sourceIndex] as string;
883+
884+
await Promise.all([
885+
browserConnection
886+
.getSourceMap(sourceId)
887+
.then((result) => {
888+
resolvedSourceMaps.set(sourceIndex, result);
889+
})
890+
.catch((e) => {
891+
console.warn(
892+
`Failed to fetch source map for "${filename}" (id=${sourceId}):`,
893+
e
894+
);
895+
}),
896+
browserConnection
897+
.getJSSource(sourceId)
898+
.then((text) => {
899+
compiledSources.set(sourceIndex, text);
900+
})
901+
.catch((e) => {
902+
console.warn(
903+
`Failed to fetch compiled source for "${filename}" (id=${sourceId}):`,
904+
e
905+
);
906+
}),
907+
]);
908+
})
909+
);
910+
} finally {
911+
dispatch({ type: 'DONE_SOURCE_MAP_FETCHING' });
912+
}
913+
914+
return { resolvedSourceMaps, compiledSources };
915+
}
916+
814917
export async function retrievePageFaviconsFromBrowser(
815918
dispatch: Dispatch,
816919
pages: PageList,

0 commit comments

Comments
 (0)