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
8 changes: 6 additions & 2 deletions packages/agent-cdp/skills/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ agent-cdp profile cpu stop # stop and save
agent-cdp profile cpu status # check if recording
agent-cdp profile cpu list [--limit N] [--offset N] # list sessions
agent-cdp profile cpu summary [--session ID] # top-level stats
agent-cdp profile cpu hotspots [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs] [--min-self-ms N] [--include-runtime]
agent-cdp profile cpu hotspots [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs] [--min-self-ms N] [--min-total-ms N] [--include-runtime]
agent-cdp profile cpu hotspot --id HOTSPOT_ID [--session ID] [--stack-limit N]
agent-cdp profile cpu modules [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs]
agent-cdp profile cpu stacks [--session ID] [--limit N] [--offset N] [--min-ms N] [--max-depth N]
Expand All @@ -266,12 +266,16 @@ agent-cdp profile cpu start --name before-optimization
# ... run the workload you want to profile ...
agent-cdp profile cpu stop
agent-cdp profile cpu hotspots --sort selfMs --limit 20
agent-cdp profile cpu hotspot --id <HOTSPOT_ID> # drill into a specific hotspot
agent-cdp profile cpu hotspot --id <HOTSPOT_ID> # drill into repeated work, child time, callers, and time ranges
```

`--interval US` sets the sampling interval in microseconds (default 100).
Lower values give finer resolution but more overhead.

Use `--sort totalMs` or `--min-total-ms` when you need inclusive time. Use
`profile cpu hotspot` to see whether a function's cost came from repeated short
runs, delegated child work, or a narrow time range in the recording.

## Common flags

```bash
Expand Down
46 changes: 45 additions & 1 deletion packages/agent-cdp/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("cli", () => {
expect(usage()).toContain("memory usage summary");
expect(usage()).toContain("memory allocation hotspots [--session ID] [--limit N] [--offset N]");
expect(usage()).toContain("memory allocation-timeline summary [--session ID]");
expect(usage()).toContain("profile cpu hotspots [--session ID] [--limit N] [--offset N]");
expect(usage()).toContain("profile cpu hotspots [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs] [--min-self-ms N] [--min-total-ms N] [--include-runtime]");
});

it("prints the preserved top-level help text", async () => {
Expand Down Expand Up @@ -167,6 +167,50 @@ describe("cli", () => {
logSpy.mockRestore();
});

it("dispatches CPU hotspot filters through commander", async () => {
const ensureDaemonMock = vi.fn().mockResolvedValue(undefined);
const sendCommandMock = vi.fn(async (command: IpcCommand): Promise<IpcResponse> => {
if (command.type === "js-profile-hotspots") {
return { ok: true, data: { sessionId: "s1", total: 0, offset: 0, items: [] } };
}

throw new Error(`Unexpected command: ${command.type}`);
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const program = createProgram({
ensureDaemon: ensureDaemonMock,
sendCommand: sendCommandMock,
stopDaemon: vi.fn(),
});

await program.parseAsync([
"profile",
"cpu",
"hotspots",
"--session",
"s1",
"--sort",
"totalMs",
"--min-self-ms",
"12.5",
"--min-total-ms",
"75",
], { from: "user" });

expect(sendCommandMock).toHaveBeenCalledWith({
type: "js-profile-hotspots",
sessionId: "s1",
limit: undefined,
offset: undefined,
sortBy: "totalMs",
minSelfMs: 12.5,
minTotalMs: 75,
includeRuntime: false,
});

logSpy.mockRestore();
});

it("auto-selects the only discovered target", async () => {
const target: TargetDescriptor = {
id: "chrome:MTI3LjAuMC4xOjkyMjI:page-1",
Expand Down
122 changes: 122 additions & 0 deletions packages/agent-cdp/src/__tests__/js-profiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { formatJsHotspotDetail } from "../js-profiler/formatters.js";
import { queryHotspotDetail, queryHotspots } from "../js-profiler/query.js";
import type { CdpProfile, JsProfileSession } from "../js-profiler/types.js";

function createSession(): JsProfileSession {
const frames = new Map([
["f-root", { frameId: "f-root", functionName: "root", url: "app:///root.ts", lineNumber: 0, columnNumber: 0, moduleName: "root.ts", isNative: false, isRuntime: false, isAnonymous: false, symbolicationStatus: "not-applicable" as const }],
["f-parent", { frameId: "f-parent", functionName: "renderList", url: "app:///parent.ts", lineNumber: 1, columnNumber: 0, moduleName: "parent.ts", isNative: false, isRuntime: false, isAnonymous: false, symbolicationStatus: "not-applicable" as const }],
["f-hot", { frameId: "f-hot", functionName: "expensiveLoop", url: "app:///hot.ts", lineNumber: 2, columnNumber: 0, moduleName: "hot.ts", isNative: false, isRuntime: false, isAnonymous: false, symbolicationStatus: "not-applicable" as const }],
["f-child", { frameId: "f-child", functionName: "buildRow", url: "app:///child.ts", lineNumber: 3, columnNumber: 0, moduleName: "child.ts", isNative: false, isRuntime: false, isAnonymous: false, symbolicationStatus: "not-applicable" as const }],
["f-other", { frameId: "f-other", functionName: "otherWork", url: "app:///other.ts", lineNumber: 4, columnNumber: 0, moduleName: "other.ts", isNative: false, isRuntime: false, isAnonymous: false, symbolicationStatus: "not-applicable" as const }],
]);

const hotspot = {
hotspotId: "h1",
frameId: "f-hot",
selfSampleCount: 3,
totalSampleCount: 4,
selfTimeMs: 3,
totalTimeMs: 4,
selfPercent: 60,
totalPercent: 80,
};

const rawProfile: CdpProfile = {
startTime: 0,
endTime: 5000,
nodes: [
{ id: 1, callFrame: { functionName: "root", scriptId: "1", url: "app:///root.ts", lineNumber: 0, columnNumber: 0 }, children: [2] },
{ id: 2, callFrame: { functionName: "renderList", scriptId: "2", url: "app:///parent.ts", lineNumber: 1, columnNumber: 0 }, children: [3, 5] },
{ id: 3, callFrame: { functionName: "expensiveLoop", scriptId: "3", url: "app:///hot.ts", lineNumber: 2, columnNumber: 0 }, children: [4] },
{ id: 4, callFrame: { functionName: "buildRow", scriptId: "4", url: "app:///child.ts", lineNumber: 3, columnNumber: 0 } },
{ id: 5, callFrame: { functionName: "otherWork", scriptId: "5", url: "app:///other.ts", lineNumber: 4, columnNumber: 0 } },
],
samples: [3, 3, 4, 5, 3],
timeDeltas: [1000, 1000, 1000, 1000, 1000],
};

return {
sessionId: "s1",
name: "cpu-session",
startedAt: 0,
stoppedAt: 5000,
durationMs: 5,
sampleCount: 5,
samplingIntervalUs: 1000,
frames,
hotspots: [
hotspot,
{ hotspotId: "h2", frameId: "f-child", selfSampleCount: 1, totalSampleCount: 1, selfTimeMs: 1, totalTimeMs: 1, selfPercent: 20, totalPercent: 20 },
{ hotspotId: "h3", frameId: "f-other", selfSampleCount: 1, totalSampleCount: 1, selfTimeMs: 1, totalTimeMs: 1, selfPercent: 20, totalPercent: 20 },
],
hotspotsById: new Map([
["h1", hotspot],
["h2", { hotspotId: "h2", frameId: "f-child", selfSampleCount: 1, totalSampleCount: 1, selfTimeMs: 1, totalTimeMs: 1, selfPercent: 20, totalPercent: 20 }],
["h3", { hotspotId: "h3", frameId: "f-other", selfSampleCount: 1, totalSampleCount: 1, selfTimeMs: 1, totalTimeMs: 1, selfPercent: 20, totalPercent: 20 }],
]),
modules: [],
stacks: [
{ stackId: "s1", frameIds: ["f-hot", "f-parent", "f-root"], frames: ["expensiveLoop", "renderList", "root"], sampleCount: 2, timeMs: 2, percent: 40 },
{ stackId: "s2", frameIds: ["f-child", "f-hot", "f-parent", "f-root"], frames: ["buildRow", "expensiveLoop", "renderList", "root"], sampleCount: 1, timeMs: 1, percent: 20 },
],
timeBuckets: Array.from({ length: 5 }, (_, index) => ({ startMs: index, endMs: index + 1, sampleCount: 1, topHotspotIds: [] })),
sampleTimestampsMs: [1, 2, 3, 4, 5],
sampleHotspotIds: ["h1", "h1", "h2", "h3", "h1"],
rawNodeToFrameId: new Map([
[1, "f-root"],
[2, "f-parent"],
[3, "f-hot"],
[4, "f-child"],
[5, "f-other"],
]),
rawProfile,
sourceMaps: {
state: "none",
bundleUrls: [],
resolvedSourceMapUrls: [],
symbolicatedFrameCount: 0,
totalMappableFrameCount: 0,
failures: [],
},
};
}

describe("js profiler queries", () => {
it("filters hotspots by total time", () => {
const result = queryHotspots(createSession(), { minTotalMs: 4 });
expect(result.items.map((item) => item.hotspotId)).toEqual(["h1"]);
});

it("explains repeated work, callers, callees, and hotspot time buckets", () => {
const result = queryHotspotDetail(createSession(), "h1");

expect(result.hotspot.delegatedTimeMs).toBe(1);
expect(result.hotspot.delegatedPercentOfTotal).toBe(25);
expect(result.occurrence).toMatchObject({
runCount: 2,
averageRunSamples: 1.5,
averageRunMs: 1.5,
longestRunSamples: 2,
longestRunMs: 2,
firstSeenMs: 1,
lastSeenMs: 5,
});
expect(result.callers[0]).toMatchObject({ functionName: "renderList", sampleCount: 4, percent: 100 });
expect(result.callees[0]).toMatchObject({ functionName: "buildRow", sampleCount: 1, percent: 100 });
expect(result.activeTimeBuckets).toEqual([
{ startMs: 1, endMs: 2, sampleCount: 1, percentOfHotspotSamples: 33.3 },
{ startMs: 2, endMs: 3, sampleCount: 1, percentOfHotspotSamples: 33.3 },
{ startMs: 4, endMs: 5, sampleCount: 1, percentOfHotspotSamples: 33.3 },
]);
});

it("formats the richer hotspot detail output", () => {
const output = formatJsHotspotDetail(queryHotspotDetail(createSession(), "h1"), true);
expect(output).toContain("Delegated to children: 25% (1ms)");
expect(output).toContain("Repeated work: 2 runs, avg 1.5ms (1.5 samples), longest 2ms (2 samples)");
expect(output).toContain("Top callers:");
expect(output).toContain("Top callees:");
expect(output).toContain("1–2ms (1 samples, 33.3% of hotspot self time)");
});
});
48 changes: 48 additions & 0 deletions packages/agent-cdp/src/__tests__/source-maps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { resolveSourceMapsForCandidates } from "../source-maps.js";

const BUNDLE_URL = "http://localhost:8081/index.bundle?platform=ios&dev=true";
const SOURCE_MAP_URL = "http://localhost:8081/index.map?platform=ios&dev=true";

describe("source maps", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("falls forward to the nearest mapped segment on the same line", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url === BUNDLE_URL) {
return new Response(`function demo() {}\n//# sourceMappingURL=${SOURCE_MAP_URL}`);
}

if (url === SOURCE_MAP_URL) {
return new Response(JSON.stringify({
version: 3,
file: "index.bundle",
sources: ["src/demo.ts"],
names: [],
mappings: ";EAAE",
}));
}

throw new Error(`Unexpected fetch: ${url}`);
}),
);

const result = await resolveSourceMapsForCandidates([
{ url: BUNDLE_URL, lineNumber: 1, columnNumber: 3 },
]);

expect(result.getOriginalPosition(BUNDLE_URL, 1, 3)).toEqual({
source: "src/demo.ts",
line: 0,
column: 2,
name: null,
});
expect(result.symbolicatedCount).toBe(1);
});
});
3 changes: 2 additions & 1 deletion packages/agent-cdp/src/cli/commands/profiling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export function registerProfilingCommands(program: Command, deps: CliDeps): void
console.log(formatJsProfileSummary(data as Parameters<typeof formatJsProfileSummary>[0], getVerbose(command)));
});

cpu.command("hotspots").option("--session <id>").option("--limit <n>").option("--offset <n>").option("--sort <sort>").option("--min-self-ms <ms>").option("--include-runtime").action(async (options: Record<string, string | boolean | undefined>, command) => {
cpu.command("hotspots").option("--session <id>").option("--limit <n>").option("--offset <n>").option("--sort <sort>").option("--min-self-ms <ms>").option("--min-total-ms <ms>").option("--include-runtime").action(async (options: Record<string, string | boolean | undefined>, command) => {
await deps.ensureDaemon();
const data = unwrapResponse(
await deps.sendCommand({
Expand All @@ -194,6 +194,7 @@ export function registerProfilingCommands(program: Command, deps: CliDeps): void
offset: parseInteger(typeof options.offset === "string" ? options.offset : undefined),
sortBy: typeof options.sort === "string" ? options.sort : undefined,
minSelfMs: parseFloatNumber(typeof options.minSelfMs === "string" ? options.minSelfMs : undefined),
minTotalMs: parseFloatNumber(typeof options.minTotalMs === "string" ? options.minTotalMs : undefined),
includeRuntime: options.includeRuntime === true,
}),
"Failed to get JS profile hotspots",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-cdp/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ CPU Profiling:
profile cpu status
profile cpu list [--limit N] [--offset N]
profile cpu summary [--session ID]
profile cpu hotspots [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs] [--min-self-ms N] [--include-runtime]
profile cpu hotspots [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs] [--min-self-ms N] [--min-total-ms N] [--include-runtime]
profile cpu hotspot --id HOTSPOT_ID [--session ID] [--stack-limit N]
profile cpu modules [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs]
profile cpu stacks [--session ID] [--limit N] [--offset N] [--min-ms N] [--max-depth N]
Expand Down
1 change: 1 addition & 0 deletions packages/agent-cdp/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ class Daemon {
offset: command.offset,
sortBy: command.sortBy,
minSelfMs: command.minSelfMs,
minTotalMs: command.minTotalMs,
includeRuntime: command.includeRuntime,
}),
};
Expand Down
28 changes: 26 additions & 2 deletions packages/agent-cdp/src/js-profiler/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,29 @@ export function formatJsHotspotDetail(result: JsHotspotDetailResult, verbose = f
}
lines.push(` Self: ${h.selfPercent}% (${h.selfTimeMs}ms, ${h.selfSampleCount} samples)`);
lines.push(` Total: ${h.totalPercent}% (${h.totalTimeMs}ms, ${h.totalSampleCount} samples)`);
lines.push(` Delegated to children: ${h.delegatedPercentOfTotal}% (${h.delegatedTimeMs}ms)`);
lines.push(
` Repeated work: ${result.occurrence.runCount} runs, avg ${result.occurrence.averageRunMs}ms (${result.occurrence.averageRunSamples} samples), longest ${result.occurrence.longestRunMs}ms (${result.occurrence.longestRunSamples} samples)`,
);
if (result.occurrence.firstSeenMs !== null && result.occurrence.lastSeenMs !== null) {
lines.push(` Seen between: ${result.occurrence.firstSeenMs}ms and ${result.occurrence.lastSeenMs}ms`);
}

if (result.callers.length > 0) {
lines.push("");
lines.push("Top callers:");
for (const caller of result.callers) {
lines.push(` ${String(caller.percent).padStart(5)}% ${String(caller.sampleCount).padStart(7)} samples ${caller.functionName} ${caller.module}`);
}
}

if (result.callees.length > 0) {
lines.push("");
lines.push("Top callees:");
for (const callee of result.callees) {
lines.push(` ${String(callee.percent).padStart(5)}% ${String(callee.sampleCount).padStart(7)} samples ${callee.functionName} ${callee.module}`);
}
}

if (result.representativeStacks.length > 0) {
lines.push("");
Expand All @@ -177,7 +200,7 @@ export function formatJsHotspotDetail(result: JsHotspotDetailResult, verbose = f
lines.push("");
lines.push("Active time ranges:");
for (const b of result.activeTimeBuckets) {
lines.push(` ${b.startMs}–${b.endMs}ms (${b.sampleCount} samples)`);
lines.push(` ${b.startMs}–${b.endMs}ms (${b.sampleCount} samples, ${b.percentOfHotspotSamples}% of hotspot self time)`);
}
}

Expand All @@ -201,7 +224,8 @@ export function formatJsHotspotDetail(result: JsHotspotDetailResult, verbose = f
location = ` ${f.url}:${f.lineNumber + 1}:${f.columnNumber}`;
}
lines.push(`${h.hotspotId} ${f.functionName} (${f.moduleName})${location}`);
lines.push(` self:${h.selfPercent}% ${h.selfTimeMs}ms total:${h.totalPercent}% ${h.totalTimeMs}ms`);
lines.push(` self:${h.selfPercent}% ${h.selfTimeMs}ms total:${h.totalPercent}% ${h.totalTimeMs}ms child:${h.delegatedTimeMs}ms`);
lines.push(` runs:${result.occurrence.runCount} avg:${result.occurrence.averageRunMs}ms max:${result.occurrence.longestRunMs}ms`);
for (const s of result.representativeStacks) {
lines.push(` ${s.stackId} ${s.percent}% ${s.frames.join(" → ")}`);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-cdp/src/js-profiler/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function normalizeProfile(

// Frame registry keyed by identity (original position when symbolicated, otherwise bundle)
const frameByKey = new Map<string, JsFrame>();
const rawNodeToFrameId = new Map<number, string>();
let frameCounter = 0;

function makeFrameKey(
Expand Down Expand Up @@ -127,7 +128,10 @@ export function normalizeProfile(
return frameByKey.get(key)!;
}

for (const node of nodes) getOrCreateFrame(node);
for (const node of nodes) {
const frame = getOrCreateFrame(node);
rawNodeToFrameId.set(node.id, frame.frameId);
}

// Ancestor chain for a node: leaf first, root last
function getAncestors(nodeId: number): number[] {
Expand Down Expand Up @@ -339,6 +343,7 @@ export function normalizeProfile(
timeBuckets,
sampleTimestampsMs,
sampleHotspotIds,
rawNodeToFrameId,
rawProfile,
sourceMaps,
};
Expand Down
Loading
Loading