Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion src/profile-logic/import/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type TracingEventUnion =
| ThreadSortIndexEvent
| ScreenshotEvent
| FallbackEndEvent
| ScriptCatchupEvent
| TracingStartedInBrowserEvent;

type TracingEvent<Event> = {
Expand Down Expand Up @@ -188,6 +189,27 @@ type TracingStartedInBrowserEvent = TracingEvent<{
ph: 'I';
}>;

// Emitted for every parsed script when a trace is recorded with the
// "disabled-by-default-devtools.v8-source-rundown" category (the DevTools
// Performance panel enables it by default). This is the only place a Chrome
// trace exposes the source map URL for a script. The CPU profile callFrames
// don't carry it (for now?).
type ScriptCatchupEvent = TracingEvent<{
Comment thread
fatadel marked this conversation as resolved.
name: 'ScriptCatchup';
args: {
data: {
executionContextId: number;
hasSourceUrl: boolean;
isModule: boolean;
isolate: string;
scriptId: number;
url?: string;
// The source map URL as declared by the script, often relative to `url`.
sourceMapUrl?: string;
};
};
}>;

function wrapCpuProfileInEvent(cpuProfile: CpuProfileData): CpuProfileEvent {
return {
name: 'CpuProfile',
Expand Down Expand Up @@ -532,6 +554,21 @@ async function processTracingEvents(
ensureExists(profile.meta.categories)
);

// Build a lookup from script URL to its source map URL from any ScriptCatchup
// events in the trace. The declared source map URL is often relative to the
// script URL which matches how Gecko profiles store this column.
// Keyed by URL because the source table is deduped by URL too, and the CPU
// profile callFrames only expose the script URL (not the isolate), which is
// what would be needed to disambiguate scriptIds across isolates.
const sourceMapURLByScriptURL = new Map<string, string>();
for (const event of (eventsByName.get('ScriptCatchup') ??
[]) as ScriptCatchupEvent[]) {
const { data } = event.args;
if (data.url && data.sourceMapUrl) {
sourceMapURLByScriptURL.set(data.url, data.sourceMapUrl);
}
}

const threadInfoByPidAndTid = new Map<string, ThreadInfo>();
const threadInfoByThread = new Map<RawThread, ThreadInfo>();
for (const profileEvent of profileEvents) {
Expand Down Expand Up @@ -628,7 +665,15 @@ async function processTracingEvents(
functionName !== '' ? functionName : '(anonymous)'
);
const source =
isJS && url ? globalDataCollector.indexForSource(null, url) : null;
isJS && url
? globalDataCollector.indexForSource(
null,
url,
1,
1,
sourceMapURLByScriptURL.get(url) ?? null
)
: null;
const resource = isJS
? globalDataCollector.indexForURIResource(url || '<unknown>')
: -1;
Expand Down
200 changes: 200 additions & 0 deletions src/test/unit/profile-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,206 @@ describe('converting Google Chrome profile', function () {
});
expect(markers).toMatchSnapshot();
});

it('captures source map URLs from ScriptCatchup events', async function () {
// Traces recorded with the "v8-source-rundown" category (on by default in
// the DevTools Performance panel) include a ScriptCatchup event per parsed
// script that declares its source map URL. Build a small trace with a CPU
// profile plus these events and check they land in the source table
// verbatim (relative URLs stay relative, matching Gecko profiles).
const chromeProfile = {
traceEvents: [
{
args: { data: { startTime: 0 } },
cat: 'disabled-by-default-v8.cpu_profiler',
id: '0x1',
name: 'Profile',
ph: 'P',
pid: 1000,
tid: 1,
ts: 0,
},
{
args: {
data: {
cpuProfile: {
nodes: [
{
callFrame: { functionName: '(root)', scriptId: 0 },
id: 1,
},
{
callFrame: {
functionName: 'relative',
scriptId: 7,
url: 'https://example.com/app.js',
lineNumber: 10,
columnNumber: 5,
},
id: 2,
parent: 1,
},
{
callFrame: {
functionName: 'absolute',
scriptId: 8,
url: 'https://example.com/vendor.js',
lineNumber: 20,
columnNumber: 3,
},
id: 3,
parent: 1,
},
{
callFrame: {
functionName: 'inline',
scriptId: 9,
url: 'https://example.com/inline.js',
lineNumber: 1,
columnNumber: 1,
},
id: 4,
parent: 1,
},
{
callFrame: {
functionName: 'nomap',
scriptId: 10,
url: 'https://example.com/nomap.js',
lineNumber: 2,
columnNumber: 2,
},
id: 5,
parent: 1,
},
],
samples: [2, 3, 4, 5],
},
timeDeltas: [0, 500, 500, 500],
},
},
cat: 'disabled-by-default-v8.cpu_profiler',
id: '0x1',
name: 'ProfileChunk',
ph: 'P',
pid: 1000,
tid: 1,
ts: 1,
},
// A source map URL relative to the script URL should be kept verbatim.
{
args: {
data: {
isolate: 'iso1',
scriptId: 7,
url: 'https://example.com/app.js',
sourceMapUrl: '/app.js.map',
},
},
cat: 'disabled-by-default-devtools.v8-source-rundown',
name: 'ScriptCatchup',
ph: 'X',
pid: 1000,
tid: 1,
ts: 0,
},
// An absolute source map URL should be kept as-is.
{
args: {
data: {
isolate: 'iso1',
scriptId: 8,
url: 'https://example.com/vendor.js',
sourceMapUrl: 'https://cdn.example.com/vendor.js.map',
},
},
cat: 'disabled-by-default-devtools.v8-source-rundown',
name: 'ScriptCatchup',
ph: 'X',
pid: 1000,
tid: 1,
ts: 0,
},
// An inline (data:) source map URL should be kept as-is.
{
args: {
data: {
isolate: 'iso1',
scriptId: 9,
url: 'https://example.com/inline.js',
sourceMapUrl: 'data:application/json;base64,e30=',
},
},
cat: 'disabled-by-default-devtools.v8-source-rundown',
name: 'ScriptCatchup',
ph: 'X',
pid: 1000,
tid: 1,
ts: 0,
},
// A script without a source map URL should leave the field null.
{
args: {
data: {
isolate: 'iso1',
scriptId: 10,
url: 'https://example.com/nomap.js',
},
},
cat: 'disabled-by-default-devtools.v8-source-rundown',
name: 'ScriptCatchup',
ph: 'X',
pid: 1000,
tid: 1,
ts: 0,
},
// A ScriptCatchup for a script that isn't referenced by the CPU profile
// should be harmless (no source is created for it).
{
args: {
data: {
isolate: 'iso1',
scriptId: 3,
url: 'extensions::SafeBuiltins',
sourceMapUrl: '/should-be-ignored.map',
},
},
cat: 'disabled-by-default-devtools.v8-source-rundown',
name: 'ScriptCatchup',
ph: 'X',
pid: 1000,
tid: 1,
ts: 0,
},
],
};

const profile = await unserializeProfileOfArbitraryFormat(
JSON.stringify(chromeProfile)
);
if (profile === undefined) {
throw new Error('Unable to parse the profile.');
}
assertProfileIntegrity(profile);

const { sources, stringArray } = profile.shared;
const getSourceMapURL = (filename: string): string | null => {
const sourceIndex = sources.filename.findIndex(
(i) => stringArray[i] === filename
);
const sourceMapURLIndex = sources.sourceMapURL[sourceIndex];
return sourceMapURLIndex === null ? null : stringArray[sourceMapURLIndex];
};

expect(getSourceMapURL('https://example.com/app.js')).toBe('/app.js.map');
expect(getSourceMapURL('https://example.com/vendor.js')).toBe(
'https://cdn.example.com/vendor.js.map'
);
expect(getSourceMapURL('https://example.com/inline.js')).toBe(
'data:application/json;base64,e30='
);
expect(getSourceMapURL('https://example.com/nomap.js')).toBe(null);
});
});

describe('converting ART trace', function () {
Expand Down
Loading