Skip to content

Commit 0b015e4

Browse files
and-oliDevtools-frontend LUCI CQ
authored andcommitted
Parse trace ids from samples in trace CPU profiles
As per go/chrome-devtools:better-js-flamecharts, this CL does the frontend bits to extract the trace id from samples and use it to obtain the stack trace of the trace event with the same trace id. This stack trace is used in the consolidation of tracing and sampling data that creates the JS calls in the thread flamecharts. Bug: 390155857 Change-Id: I6c21eb9a0b8ecf61422c249d6ebe9fa68091c3eb Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6218850 Commit-Queue: Andres Olivares <[email protected]> Reviewed-by: Jack Franklin <[email protected]>
1 parent 7bcf89b commit 0b015e4

File tree

7 files changed

+157
-13
lines changed

7 files changed

+157
-13
lines changed

front_end/models/cpu_profile/CPUProfileDataModel.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export class CPUProfileDataModel extends ProfileTreeModel {
5050
profileEndTime: number /* milliseconds */;
5151
timestamps: number[];
5252
samples: number[]|undefined;
53+
/**
54+
* Contains trace ids assigned to samples, if any. Trace ids are
55+
* keyed by the sample index in the profile. These are only created
56+
* for CPU profiles coming from traces.
57+
*/
58+
traceIds?: Record<string, number>;
5359
lines?: number[];
5460
totalHitCount: number;
5561
profileHead: CPUProfileNode;
@@ -81,6 +87,7 @@ export class CPUProfileDataModel extends ProfileTreeModel {
8187
this.profileEndTime = profile.endTime / 1000;
8288
this.timestamps = this.convertTimeDeltas(profile);
8389
}
90+
this.traceIds = profile.traceIds;
8491
this.samples = profile.samples;
8592

8693
// Lines are available only in profiles coming from tracing.
@@ -547,5 +554,8 @@ export class CPUProfileDataModel extends ProfileTreeModel {
547554

548555
// Format used by profiles coming from traces.
549556
export type ExtendedProfileNode = Protocol.Profiler.ProfileNode&{parent?: number};
550-
export type ExtendedProfile =
551-
Protocol.Profiler.Profile&{nodes: Protocol.Profiler.ProfileNode[] | ExtendedProfileNode[], lines?: number[]};
557+
export type ExtendedProfile = Protocol.Profiler.Profile&{
558+
nodes: Protocol.Profiler.ProfileNode[] | ExtendedProfileNode[],
559+
lines?: number[],
560+
traceIds?: Record<string, number>,
561+
};

front_end/models/trace/handlers/SamplesHandler.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ describeWithEnvironment('SamplesHandler', function() {
255255
assert.strictEqual(profileById.size, 1);
256256
const cpuProfileData = profileById.values().next().value as Trace.Handlers.ModelHandlers.Samples.ProfileData;
257257
const cpuProfile = cpuProfileData.rawProfile;
258-
assert.deepEqual(Object.keys(cpuProfile), ['startTime', 'endTime', 'nodes', 'samples', 'timeDeltas', 'lines']);
258+
assert.deepEqual(
259+
Object.keys(cpuProfile), ['startTime', 'endTime', 'nodes', 'samples', 'timeDeltas', 'lines', 'traceIds']);
259260
assert.lengthOf(cpuProfile.nodes, 153);
260261
assert.strictEqual(cpuProfile.startTime, 287510826176);
261262
assert.strictEqual(cpuProfile.endTime, 287510847633);

front_end/models/trace/handlers/SamplesHandler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export function handleEvent(event: Types.Events.Event): void {
152152
const cdpProfile = profileData.rawProfile;
153153
const nodesAndSamples: Types.Events.PartialProfile|undefined = event.args?.data?.cpuProfile || {samples: []};
154154
const samples = nodesAndSamples?.samples || [];
155+
const traceIds = event.args?.data?.cpuProfile?.trace_ids || {};
155156
const nodes: CPUProfile.CPUProfileDataModel.ExtendedProfileNode[] = [];
156157
for (const n of nodesAndSamples?.nodes || []) {
157158
const lineNumber = typeof n.callFrame.lineNumber === 'undefined' ? -1 : n.callFrame.lineNumber;
@@ -178,6 +179,7 @@ export function handleEvent(event: Types.Events.Event): void {
178179
cdpProfile.samples?.push(...samples);
179180
cdpProfile.timeDeltas?.push(...timeDeltas);
180181
cdpProfile.lines?.push(...lines);
182+
cdpProfile.traceIds = {...(cdpProfile.traceIds || {}), ...traceIds};
181183
if (cdpProfile.samples && cdpProfile.timeDeltas && cdpProfile.samples.length !== cdpProfile.timeDeltas.length) {
182184
console.error('Failed to parse CPU profile.');
183185
return;

front_end/models/trace/helpers/SamplesIntegrator.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,74 @@ describeWithEnvironment('SamplesIntegrator', function() {
225225
assert.strictEqual(constructedCalls[1].ts, 300);
226226
assert.strictEqual(constructedCalls[1].dur, 200);
227227
});
228+
it('uses the stack in a sample for a trace event when connected via a common trace id ', () => {
229+
const traceId = 123;
230+
const cdpProfile: CPUProfile.CPUProfileDataModel.ExtendedProfile = {
231+
startTime: 0,
232+
endTime: 3000,
233+
nodes: [
234+
{
235+
id: 1,
236+
hitCount: 0,
237+
callFrame: {functionName: '(root)', scriptId, url, lineNumber, columnNumber},
238+
children: [2],
239+
},
240+
{id: 2, callFrame: {functionName: 'foo', scriptId, url, lineNumber, columnNumber}, children: [3, 4]},
241+
{id: 3, callFrame: {functionName: 'bar', scriptId, url, lineNumber, columnNumber}},
242+
{id: 4, callFrame: {functionName: 'baz', scriptId, url, lineNumber, columnNumber}, children: [5]},
243+
{id: 5, callFrame: {functionName: 'sheep', scriptId, url, lineNumber, columnNumber}},
244+
],
245+
samples: [3, 3, 3, 5],
246+
traceIds: {3: traceId},
247+
timeDeltas: new Array(4).fill(100),
248+
};
249+
250+
// Before integrating samples and trace events, the flamechart
251+
// looks roughly like:
252+
// |----------------EvaluateScript-----------------|
253+
// |-----------------v8.run--------------------|
254+
// |---TimeStamp--|
255+
// | foo | | foo | | foo | | foo |
256+
// | bar | | bar | | bar | | baz |
257+
// |sheep|
258+
259+
// After integrating, it should look as follows. note that the the
260+
// stack in the last sample should be used to parent `TimeStamp`,
261+
// event if that sample's ts is later, since that event and that
262+
// sample are connected by a trace id.
263+
// |----------------EvaluateScript-----------------|
264+
// |-----------------v8.run--------------------|
265+
// |------------------foo-------------------|
266+
// |----------bar--------||--------baz------|
267+
// |------sheep------|
268+
// |-Timestamp-|
269+
270+
const parsedProfile = new CPUProfile.CPUProfileDataModel.CPUProfileDataModel(cdpProfile);
271+
const integrator = new Trace.Helpers.SamplesIntegrator.SamplesIntegrator(parsedProfile, PROFILE_ID, pid, tid);
272+
const evaluateScript = makeCompleteEvent(Trace.Types.Events.Name.EVALUATE_SCRIPT, 0, 500);
273+
const v8Run = makeCompleteEvent('v8.run', 10, 490);
274+
const consoleTimeStamp =
275+
makeCompleteEvent(Trace.Types.Events.Name.CONSOLE_TIME_STAMP, 350, 10) as Trace.Types.Events.ConsoleTimeStamp;
276+
consoleTimeStamp.args = {data: {name: 'A timestamp', sampleTraceId: traceId}};
277+
const traceEvents = [evaluateScript, v8Run, consoleTimeStamp];
278+
const constructedCalls = integrator.buildProfileCalls(traceEvents);
279+
assert.lengthOf(constructedCalls, 4);
280+
assert.strictEqual(constructedCalls[0].callFrame.functionName, 'foo');
281+
assert.strictEqual(constructedCalls[0].ts, 100);
282+
assert.strictEqual(constructedCalls[0].dur, 400); // 100 - 500
283+
284+
assert.strictEqual(constructedCalls[1].callFrame.functionName, 'bar');
285+
assert.strictEqual(constructedCalls[1].ts, 100);
286+
assert.strictEqual(constructedCalls[1].dur, 250); // 100 - 350
287+
288+
assert.strictEqual(constructedCalls[2].callFrame.functionName, 'baz');
289+
assert.strictEqual(constructedCalls[2].ts, 350);
290+
assert.strictEqual(constructedCalls[2].dur, 150); // 350 - 500
291+
292+
assert.strictEqual(constructedCalls[3].callFrame.functionName, 'sheep');
293+
assert.strictEqual(constructedCalls[3].ts, 350);
294+
assert.strictEqual(constructedCalls[3].dur, 150); // 350 - 500
295+
});
228296
it('restarts the call frame stack when a new top level event is encountered', () => {
229297
// Profile contains the following samples:
230298
// |a||a||a||a|

front_end/models/trace/helpers/SamplesIntegrator.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type * as CPUProfile from '../../cpu_profile/cpu_profile.js';
77
import * as Types from '../types/types.js';
88

99
import {milliToMicro} from './Timing.js';
10-
import {makeProfileCall, mergeEventsInOrder} from './Trace.js';
10+
import {extractSampleTraceId, makeProfileCall, mergeEventsInOrder} from './Trace.js';
1111

1212
/**
1313
* This is a helper that integrates CPU profiling data coming in the
@@ -70,6 +70,12 @@ export class SamplesIntegrator {
7070
* in the stack before the event came.
7171
*/
7272
#lockedJsStackDepth: number[] = [];
73+
/**
74+
* For samples with a trace id, creates a profile call and keeps it
75+
* in a record keyed by that id. The value is typed as an union with
76+
* undefined to avoid nullish accesses when a key is not present.
77+
*/
78+
#callsByTraceIds: Record<number, Types.Events.SyntheticProfileCall|undefined> = {};
7379
/**
7480
* Used to keep track when samples should be integrated even if they
7581
* are not children of invocation trace events. This is useful in
@@ -243,11 +249,18 @@ export class SamplesIntegrator {
243249
if (!node) {
244250
continue;
245251
}
252+
const maybeTraceId = this.#profileModel.traceIds?.[i];
246253
const call = makeProfileCall(node, this.#profileId, i, timestamp, this.#processId, this.#threadId);
247-
calls.push(call);
248-
254+
// Separate samples with trace ids so that they are only used when
255+
// processing the owner event.
256+
if (maybeTraceId === undefined) {
257+
calls.push(call);
258+
} else {
259+
this.#callsByTraceIds[maybeTraceId] = call;
260+
}
249261
if (debugModeEnabled) {
250-
this.jsSampleEvents.push(this.#makeJSSampleEvent(call, timestamp));
262+
const traceId = this.#profileModel.traceIds?.[i];
263+
this.jsSampleEvents.push(this.#makeJSSampleEvent(call, timestamp, traceId));
251264
}
252265
if (node.id === this.#profileModel.gcNode?.id && prevNode) {
253266
// GC samples have no stack, so we just put GC node on top of the
@@ -261,7 +274,23 @@ export class SamplesIntegrator {
261274
return calls;
262275
}
263276

264-
#makeProfileCallsForStack(profileCall: Types.Events.SyntheticProfileCall): Types.Events.SyntheticProfileCall[] {
277+
/**
278+
* Given a synthetic profile call, returns an array of profile calls
279+
* representing the stack trace that profile call belongs to based on
280+
* its nodeId. The input profile call will be at the top of the
281+
* returned stack (last position), meaning that any other frames that
282+
* were effectively above it are omitted.
283+
* @param profileCall
284+
* @param overrideTimeStamp a custom timestamp to use for the returned
285+
* profile calls. If not defined, the timestamp of the input
286+
* profileCall is used instead. This param is useful for example when
287+
* creating the profile calls for a sample with a trace id, since the
288+
* timestamp of the corresponding trace event should be used instead
289+
* of the sample's.
290+
*/
291+
292+
#makeProfileCallsForStack(profileCall: Types.Events.SyntheticProfileCall, overrideTimeStamp?: Types.Timing.Micro):
293+
Types.Events.SyntheticProfileCall[] {
265294
let node = this.#profileModel.nodeById(profileCall.nodeId);
266295
const isGarbageCollection = node?.id === this.#profileModel.gcNode?.id;
267296
if (isGarbageCollection) {
@@ -286,7 +315,8 @@ export class SamplesIntegrator {
286315
// durations
287316
while (node) {
288317
callFrames[i--] = makeProfileCall(
289-
node, profileCall.profileId, profileCall.sampleIndex, profileCall.ts, this.#processId, this.#threadId);
318+
node, profileCall.profileId, profileCall.sampleIndex, overrideTimeStamp ?? profileCall.ts, this.#processId,
319+
this.#threadId);
290320
node = node.parent;
291321
}
292322
return callFrames;
@@ -296,7 +326,18 @@ export class SamplesIntegrator {
296326
* Update tracked stack using this event's call stack.
297327
*/
298328
#extractStackTrace(event: Types.Events.Event): void {
299-
const stackTrace = Types.Events.isProfileCall(event) ? this.#makeProfileCallsForStack(event) : this.#currentJSStack;
329+
let stackTrace = this.#currentJSStack;
330+
if (Types.Events.isProfileCall(event)) {
331+
stackTrace = this.#makeProfileCallsForStack(event);
332+
}
333+
const traceId = extractSampleTraceId(event);
334+
if (traceId !== null) {
335+
const maybeCallForTraceId = this.#callsByTraceIds[traceId];
336+
if (maybeCallForTraceId) {
337+
stackTrace = this.#makeProfileCallsForStack(maybeCallForTraceId, event.ts);
338+
}
339+
}
340+
300341
SamplesIntegrator.filterStackFrames(stackTrace, this.#engineConfig);
301342

302343
const endTime = event.ts + (event.dur || 0);
@@ -391,13 +432,13 @@ export class SamplesIntegrator {
391432
this.#currentJSStack.length = depth;
392433
}
393434

394-
#makeJSSampleEvent(call: Types.Events.SyntheticProfileCall, timestamp: Types.Timing.Micro):
435+
#makeJSSampleEvent(call: Types.Events.SyntheticProfileCall, timestamp: Types.Timing.Micro, traceId?: number):
395436
Types.Events.SyntheticJSSample {
396437
const JSSampleEvent: Types.Events.SyntheticJSSample = {
397438
name: Types.Events.Name.JS_SAMPLE,
398439
cat: 'devtools.timeline',
399440
args: {
400-
data: {stackTrace: this.#makeProfileCallsForStack(call).map(e => e.callFrame)},
441+
data: {traceId, stackTrace: this.#makeProfileCallsForStack(call).map(e => e.callFrame)},
401442
},
402443
ph: Types.Events.Phase.INSTANT,
403444
ts: timestamp,

front_end/models/trace/helpers/Trace.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,3 +687,10 @@ export function isMatchingCallFrame(
687687
export function eventContainsTimestamp(event: Types.Events.Event, ts: Types.Timing.Micro): boolean {
688688
return event.ts <= ts && event.ts + (event.dur || 0) >= ts;
689689
}
690+
691+
export function extractSampleTraceId(event: Types.Events.Event): number|null {
692+
if (Types.Events.isConsoleRunTask(event) || Types.Events.isConsoleTimeStamp(event)) {
693+
return event.args?.data?.sampleTraceId || null;
694+
}
695+
return null;
696+
}

front_end/models/trace/types/TraceEvents.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ export interface ProfileChunk extends Sample {
174174
export interface PartialProfile {
175175
nodes?: PartialNode[];
176176
samples: CallFrameID[];
177+
/**
178+
* Contains trace ids assigned to samples, if any. Trace ids are
179+
* keyed by the sample index in the profile (the keys of the object
180+
* are strings containing the numeric index).
181+
*/
182+
/* eslint-disable @typescript-eslint/naming-convention */
183+
trace_ids?: Record<string, number>;
184+
/* eslint-enable @typescript-eslint/naming-convention */
177185
}
178186

179187
export interface PartialNode {
@@ -1421,6 +1429,7 @@ export interface ConsoleTimeStamp extends Event {
14211429
track?: string|number,
14221430
trackGroup?: string|number,
14231431
color?: string|number,
1432+
sampleTraceId?: number,
14241433
},
14251434
};
14261435
}
@@ -1643,7 +1652,8 @@ export interface SyntheticJSSample extends Event {
16431652
name: Name.JS_SAMPLE;
16441653
args: Args&{
16451654
data: ArgsData & {
1646-
stackTrace: Protocol.Runtime.CallFrame[],
1655+
// Used to associate a stack sample with a trace event.
1656+
traceId?: number, stackTrace: Protocol.Runtime.CallFrame[],
16471657
},
16481658
};
16491659
ph: Phase.INSTANT;
@@ -2762,6 +2772,11 @@ export function isJSInvocationEvent(event: Event): boolean {
27622772
return false;
27632773
}
27642774
export interface ConsoleRunTask extends Event {
2775+
args: Args&{
2776+
data: ArgsData & {
2777+
sampleTraceId?: number,
2778+
},
2779+
};
27652780
name: Name.V8_CONSOLE_RUN_TASK;
27662781
}
27672782

0 commit comments

Comments
 (0)