chrome-devtools-frontend
Version:
Chrome DevTools UI
212 lines (188 loc) • 9.09 kB
text/typescript
// Copyright 2024 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Trace from '../../../models/trace/trace.js';
import {nameForEntry} from './EntryName.js';
import {visibleTypes} from './EntryStyles.js';
import {SourceMapsResolver} from './SourceMapsResolver.js';
/** Iterates from a node down through its descendents. If the callback returns true, the loop stops. */
function depthFirstWalk(
nodes: MapIterator<Trace.Extras.TraceTree.Node>, callback: (arg0: Trace.Extras.TraceTree.Node) => void|true): void {
for (const node of nodes) {
if (callback?.(node)) {
break;
}
depthFirstWalk(node.children().values(), callback); // Go deeper.
}
}
export class AICallTree {
constructor(
public selectedNode: Trace.Extras.TraceTree.Node,
public rootNode: Trace.Extras.TraceTree.TopDownRootNode,
// TODO: see if we can avoid passing around this entire thing.
public parsedTrace: Trace.Handlers.Types.ParsedTrace,
) {
}
/**
* Attempts to build an AICallTree from a given selected event. It also
* validates that this event is one that we support being used with the AI
* Assistance panel, which [as of January 2025] means:
* 1. It is on the main thread.
* 2. It exists in either the Renderer or Sample handler's entryToNode map.
* This filters out other events we make such as SyntheticLayoutShifts which are not valid
* If the event is not valid, or there is an unexpected error building the tree, `null` is returned.
*/
static from(selectedEvent: Trace.Types.Events.Event, parsedTrace: Trace.Handlers.Types.ParsedTrace): AICallTree|null {
// First: check that the selected event is on the thread we have identified as the main thread.
const threads = Trace.Handlers.Threads.threadsInTrace(parsedTrace);
const thread = threads.find(t => t.pid === selectedEvent.pid && t.tid === selectedEvent.tid);
if (!thread) {
return null;
}
// We allow two thread types to deal with the NodeJS use case.
// MAIN_THREAD is used when a trace has been generated through Chrome
// tracing on a website (and we have a renderer)
// CPU_PROFILE is used only when we have received a CPUProfile - in this
// case all the threads are CPU_PROFILE so we allow those. If we only allow
// MAIN_THREAD then we wouldn't ever allow NodeJS users to use the AI
// integration.
if (thread.type !== Trace.Handlers.Threads.ThreadType.MAIN_THREAD &&
thread.type !== Trace.Handlers.Threads.ThreadType.CPU_PROFILE) {
return null;
}
// Ensure that the event is known to either the Renderer or Samples
// handler. This helps exclude synthetic events we build up for other
// information such as Layout Shift clusters.
// We check Renderer + Samples to ensure we support CPU Profiles (which do
// not populate the Renderer Handler)
if (!parsedTrace.Renderer.entryToNode.has(selectedEvent) && !parsedTrace.Samples.entryToNode.has(selectedEvent)) {
return null;
}
const {startTime, endTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(selectedEvent);
const selectedEventBounds = Trace.Helpers.Timing.traceWindowFromMicroSeconds(
Trace.Helpers.Timing.milliToMicro(startTime), Trace.Helpers.Timing.milliToMicro(endTime));
let threadEvents = parsedTrace.Renderer.processes.get(selectedEvent.pid)?.threads.get(selectedEvent.tid)?.entries;
if (!threadEvents) {
// None from the renderer: try the samples handler, this might be a CPU trace.
threadEvents = parsedTrace.Samples.profilesInProcess.get(selectedEvent.pid)?.get(selectedEvent.tid)?.profileCalls;
}
if (!threadEvents) {
console.warn(`AICallTree: could not find thread for selected entry: ${selectedEvent}`);
return null;
}
const overlappingEvents = threadEvents.filter(e => Trace.Helpers.Timing.eventIsInBounds(e, selectedEventBounds));
const visibleEventsFilter = new Trace.Extras.TraceFilter.VisibleEventsFilter(visibleTypes());
const customFilter = new AITreeFilter(selectedEvent);
// Build a tree bounded by the selected event's timestamps, and our other filters applied
const rootNode = new Trace.Extras.TraceTree.TopDownRootNode(overlappingEvents, {
filters: [visibleEventsFilter, customFilter],
startTime,
endTime,
includeInstantEvents: true,
});
// Walk the tree to find selectedNode
let selectedNode: Trace.Extras.TraceTree.Node|null = null;
depthFirstWalk([rootNode].values(), node => {
if (node.event === selectedEvent) {
selectedNode = node;
return true;
}
return;
});
if (selectedNode === null) {
console.warn(`Selected event ${selectedEvent} not found within its own tree.`);
return null;
}
const instance = new AICallTree(selectedNode, rootNode, parsedTrace);
// instance.logDebug();
return instance;
}
/** Define precisely how the call tree is serialized. Typically called from within `PerformanceAgent` */
serialize(): string {
const nodeToIdMap = new Map<Trace.Extras.TraceTree.Node, number>();
// Keep a map of URLs. We'll output a LUT to keep size down.
const allUrls: string[] = [];
let nodesStr = '';
depthFirstWalk(this.rootNode.children().values(), node => {
nodesStr += AICallTree.stringifyNode(node, this.parsedTrace, this.selectedNode, nodeToIdMap, allUrls);
});
let output = '';
if (allUrls.length) {
// Output lookup table of URLs within this tree
output += '\n# All URL #s:\n\n' + allUrls.map((url, index) => ` * ${index}: ${url}`).join('\n');
}
output += '\n\n# Call tree:' + nodesStr;
return output;
}
/* This custom YAML-like format with an adjacency list for children is 35% more token efficient than JSON */
static stringifyNode(
node: Trace.Extras.TraceTree.Node, parsedTrace: Trace.Handlers.Types.ParsedTrace,
selectedNode: Trace.Extras.TraceTree.Node, nodeToIdMap: Map<Trace.Extras.TraceTree.Node, number>,
allUrls: string[]): string {
const event = node.event;
if (!event) {
throw new Error('Event required');
}
const url = SourceMapsResolver.resolvedURLForEntry(parsedTrace, event);
// Get the index of the URL within allUrls, and push if needed. Set to -1 if there's no URL here.
const urlIndex = !url ? -1 : allUrls.indexOf(url) === -1 ? allUrls.push(url) - 1 : allUrls.indexOf(url);
const children = Array.from(node.children().values());
// Identifier string includes an id and name:
// eg "[13] Parse HTML" or "[45] parseCPUProfileFormatFromFile"
const getIdentifier = (node: Trace.Extras.TraceTree.Node): string => {
if (!node.event || typeof node.id !== 'string') {
throw new Error('ok');
}
if (!nodeToIdMap.has(node)) {
nodeToIdMap.set(node, nodeToIdMap.size + 1);
}
return `${nodeToIdMap.get(node)} – ${nameForEntry(node.event, parsedTrace)}`;
};
// Round milliseconds because we don't need the precision
const roundToTenths = (num: number): number => Math.round(num * 10) / 10;
// Build a multiline string describing this callframe node
const lines = [
`\n\nNode: ${getIdentifier(node)}`,
selectedNode === node && 'Selected: true',
node.totalTime && `dur: ${roundToTenths(node.totalTime)}`,
// node.functionSource && `snippet: ${node.functionSource.slice(0, 250)}`,
node.selfTime && `self: ${roundToTenths(node.selfTime)}`,
urlIndex !== -1 && `URL #: ${urlIndex}`,
];
if (children.length) {
lines.push('Children:');
lines.push(...children.map(node => ` * ${getIdentifier(node)}`));
}
return lines.filter(Boolean).join('\n');
}
// Only used for debugging.
logDebug(): void {
const str = this.serialize();
// eslint-disable-next-line no-console
console.log('🎆', str);
if (str.length > 45_000) {
// Manual testing shows 45k fits. 50k doesnt.
// Max is 32k _tokens_, but tokens to bytes is wishywashy, so... hard to know for sure.
console.warn('Output will likely not fit in the context window. Expect an AIDA error.');
}
}
}
export class AITreeFilter extends Trace.Extras.TraceFilter.TraceFilter {
#minDuration: Trace.Types.Timing.Micro;
#selectedEvent: Trace.Types.Events.Event;
constructor(selectedEvent: Trace.Types.Events.Event) {
super();
// The larger the selected event is, the less small ones matter. We'll exclude items under ½% of the selected event's size
this.#minDuration = Trace.Types.Timing.Micro((selectedEvent.dur ?? 1) * 0.005);
this.#selectedEvent = selectedEvent;
}
accept(event: Trace.Types.Events.Event): boolean {
if (event === this.#selectedEvent) {
return true;
}
if (event.name === Trace.Types.Events.Name.COMPILE_CODE) {
return false;
}
return event.dur ? event.dur >= this.#minDuration : false;
}
}