diff --git a/packages/agent-cdp/skills/core.md b/packages/agent-cdp/skills/core.md index 963a24d..79610c9 100644 --- a/packages/agent-cdp/skills/core.md +++ b/packages/agent-cdp/skills/core.md @@ -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] @@ -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 # drill into a specific hotspot +agent-cdp profile cpu 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 diff --git a/packages/agent-cdp/src/__tests__/cli.test.ts b/packages/agent-cdp/src/__tests__/cli.test.ts index d5c763f..2a49148 100644 --- a/packages/agent-cdp/src/__tests__/cli.test.ts +++ b/packages/agent-cdp/src/__tests__/cli.test.ts @@ -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 () => { @@ -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 => { + 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", diff --git a/packages/agent-cdp/src/__tests__/js-profiler.test.ts b/packages/agent-cdp/src/__tests__/js-profiler.test.ts new file mode 100644 index 0000000..24189c0 --- /dev/null +++ b/packages/agent-cdp/src/__tests__/js-profiler.test.ts @@ -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)"); + }); +}); diff --git a/packages/agent-cdp/src/__tests__/source-maps.test.ts b/packages/agent-cdp/src/__tests__/source-maps.test.ts new file mode 100644 index 0000000..e0dafc5 --- /dev/null +++ b/packages/agent-cdp/src/__tests__/source-maps.test.ts @@ -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(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); + }); +}); diff --git a/packages/agent-cdp/src/cli/commands/profiling.ts b/packages/agent-cdp/src/cli/commands/profiling.ts index 17b757f..3a6ca29 100644 --- a/packages/agent-cdp/src/cli/commands/profiling.ts +++ b/packages/agent-cdp/src/cli/commands/profiling.ts @@ -184,7 +184,7 @@ export function registerProfilingCommands(program: Command, deps: CliDeps): void console.log(formatJsProfileSummary(data as Parameters[0], getVerbose(command))); }); - cpu.command("hotspots").option("--session ").option("--limit ").option("--offset ").option("--sort ").option("--min-self-ms ").option("--include-runtime").action(async (options: Record, command) => { + cpu.command("hotspots").option("--session ").option("--limit ").option("--offset ").option("--sort ").option("--min-self-ms ").option("--min-total-ms ").option("--include-runtime").action(async (options: Record, command) => { await deps.ensureDaemon(); const data = unwrapResponse( await deps.sendCommand({ @@ -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", diff --git a/packages/agent-cdp/src/cli/help.ts b/packages/agent-cdp/src/cli/help.ts index 3e6cfff..f1d9ba7 100644 --- a/packages/agent-cdp/src/cli/help.ts +++ b/packages/agent-cdp/src/cli/help.ts @@ -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] diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index 0ff54b5..0b716d9 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -383,6 +383,7 @@ class Daemon { offset: command.offset, sortBy: command.sortBy, minSelfMs: command.minSelfMs, + minTotalMs: command.minTotalMs, includeRuntime: command.includeRuntime, }), }; diff --git a/packages/agent-cdp/src/js-profiler/formatters.ts b/packages/agent-cdp/src/js-profiler/formatters.ts index 60b9efc..dfae2ac 100644 --- a/packages/agent-cdp/src/js-profiler/formatters.ts +++ b/packages/agent-cdp/src/js-profiler/formatters.ts @@ -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(""); @@ -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)`); } } @@ -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(" → ")}`); } diff --git a/packages/agent-cdp/src/js-profiler/normalize.ts b/packages/agent-cdp/src/js-profiler/normalize.ts index 13a7805..e1147f9 100644 --- a/packages/agent-cdp/src/js-profiler/normalize.ts +++ b/packages/agent-cdp/src/js-profiler/normalize.ts @@ -64,6 +64,7 @@ export function normalizeProfile( // Frame registry keyed by identity (original position when symbolicated, otherwise bundle) const frameByKey = new Map(); + const rawNodeToFrameId = new Map(); let frameCounter = 0; function makeFrameKey( @@ -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[] { @@ -339,6 +343,7 @@ export function normalizeProfile( timeBuckets, sampleTimestampsMs, sampleHotspotIds, + rawNodeToFrameId, rawProfile, sourceMaps, }; diff --git a/packages/agent-cdp/src/js-profiler/query.ts b/packages/agent-cdp/src/js-profiler/query.ts index 838ab93..cf11f8c 100644 --- a/packages/agent-cdp/src/js-profiler/query.ts +++ b/packages/agent-cdp/src/js-profiler/query.ts @@ -1,4 +1,5 @@ import type { + CdpProfile, JsDiffResult, JsHotspotDetailResult, JsHotspotsResult, @@ -130,6 +131,7 @@ export interface HotspotsOptions { offset?: number; sortBy?: string; minSelfMs?: number; + minTotalMs?: number; includeRuntime?: boolean; } @@ -142,6 +144,7 @@ export function queryHotspots(session: JsProfileSession, opts: HotspotsOptions): if (!frame) return false; if (!opts.includeRuntime && frame.isRuntime) return false; if (opts.minSelfMs !== undefined && h.selfTimeMs < opts.minSelfMs) return false; + if (opts.minTotalMs !== undefined && h.totalTimeMs < opts.minTotalMs) return false; return true; }); @@ -183,17 +186,21 @@ export function queryHotspotDetail( .slice(0, stackLimit) .map((s) => ({ stackId: s.stackId, percent: round1(s.percent), frames: s.frames })); - const activeTimeBuckets = session.timeBuckets - .filter((b) => b.topHotspotIds.includes(hotspotId)) - .map((b) => ({ startMs: Math.round(b.startMs), endMs: Math.round(b.endMs), sampleCount: b.sampleCount })); + const averageSampleMs = session.sampleCount > 0 ? session.durationMs / session.sampleCount : 0; + const occurrence = summarizeOccurrences(session, hotspotId, averageSampleMs); + const { callers, callees } = summarizeRelations(session, frame.frameId); + const activeTimeBuckets = summarizeHotspotBuckets(session, hotspotId); + const delegatedTimeMs = Math.max(0, hotspot.totalTimeMs - hotspot.selfTimeMs); return { hotspot: { hotspotId: hotspot.hotspotId, selfTimeMs: round1(hotspot.selfTimeMs), totalTimeMs: round1(hotspot.totalTimeMs), + delegatedTimeMs: round1(delegatedTimeMs), selfPercent: round1(hotspot.selfPercent), totalPercent: round1(hotspot.totalPercent), + delegatedPercentOfTotal: round1(hotspot.totalTimeMs > 0 ? (delegatedTimeMs / hotspot.totalTimeMs) * 100 : 0), selfSampleCount: hotspot.selfSampleCount, totalSampleCount: hotspot.totalSampleCount, }, @@ -212,11 +219,154 @@ export function queryHotspotDetail( bundleColumnNumber: frame.bundleColumnNumber, }, representativeStacks, + occurrence, + callers, + callees, activeTimeBuckets, caveats: CAVEATS_DEFAULT, }; } +function summarizeOccurrences( + session: JsProfileSession, + hotspotId: string, + averageSampleMs: number, +): JsHotspotDetailResult["occurrence"] { + let runCount = 0; + let currentRunSamples = 0; + let longestRunSamples = 0; + let firstSeenMs: number | null = null; + let lastSeenMs: number | null = null; + + for (let i = 0; i < session.sampleHotspotIds.length; i++) { + if (session.sampleHotspotIds[i] === hotspotId) { + currentRunSamples++; + if (firstSeenMs === null) firstSeenMs = session.sampleTimestampsMs[i] ?? null; + lastSeenMs = session.sampleTimestampsMs[i] ?? null; + continue; + } + + if (currentRunSamples > 0) { + runCount++; + longestRunSamples = Math.max(longestRunSamples, currentRunSamples); + currentRunSamples = 0; + } + } + + if (currentRunSamples > 0) { + runCount++; + longestRunSamples = Math.max(longestRunSamples, currentRunSamples); + } + + const selfSamples = session.hotspotsById.get(hotspotId)?.selfSampleCount ?? 0; + return { + runCount, + averageRunSamples: round1(runCount > 0 ? selfSamples / runCount : 0), + averageRunMs: round1(runCount > 0 ? (selfSamples * averageSampleMs) / runCount : 0), + longestRunSamples, + longestRunMs: round1(longestRunSamples * averageSampleMs), + firstSeenMs: firstSeenMs === null ? null : Math.round(firstSeenMs), + lastSeenMs: lastSeenMs === null ? null : Math.round(lastSeenMs), + }; +} + +function summarizeRelations( + session: JsProfileSession, + frameId: string, +): Pick { + const profile = session.rawProfile as CdpProfile; + const parentById = new Map(); + + for (const node of profile.nodes ?? []) { + for (const childId of node.children ?? []) parentById.set(childId, node.id); + } + + const callerCounts = new Map(); + const calleeCounts = new Map(); + + for (const sampleNodeId of profile.samples ?? []) { + let current: number | undefined = sampleNodeId; + const stackFrameIds: string[] = []; + const visited = new Set(); + + while (current !== undefined && !visited.has(current)) { + visited.add(current); + const normalizedFrameId = session.rawNodeToFrameId.get(current); + if (normalizedFrameId) stackFrameIds.push(normalizedFrameId); + current = parentById.get(current); + } + + const hotspotIndex = stackFrameIds.indexOf(frameId); + if (hotspotIndex === -1) continue; + + const calleeFrameId = stackFrameIds[hotspotIndex - 1]; + const callerFrameId = stackFrameIds[hotspotIndex + 1]; + + if (calleeFrameId && session.frames.get(calleeFrameId)?.isRuntime !== true) { + calleeCounts.set(calleeFrameId, (calleeCounts.get(calleeFrameId) ?? 0) + 1); + } + + if (callerFrameId && session.frames.get(callerFrameId)?.isRuntime !== true) { + callerCounts.set(callerFrameId, (callerCounts.get(callerFrameId) ?? 0) + 1); + } + } + + return { + callers: relationEntries(session, callerCounts), + callees: relationEntries(session, calleeCounts), + }; +} + +function relationEntries( + session: JsProfileSession, + counts: Map, +): Array<{ functionName: string; module: string; sampleCount: number; percent: number }> { + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .flatMap(([frameId, sampleCount]) => { + const frame = session.frames.get(frameId); + if (!frame) return []; + + return [{ + functionName: frame.functionName, + module: frame.moduleName, + sampleCount, + percent: round1(total > 0 ? (sampleCount / total) * 100 : 0), + }]; + }); +} + +function summarizeHotspotBuckets( + session: JsProfileSession, + hotspotId: string, +): JsHotspotDetailResult["activeTimeBuckets"] { + const bucketCount = Math.max(session.timeBuckets.length, 1); + const bucketWidthMs = session.durationMs > 0 ? session.durationMs / bucketCount : 1; + const hotspotSamples = session.hotspotsById.get(hotspotId)?.selfSampleCount ?? 0; + const counts = Array.from({ length: bucketCount }, () => 0); + + for (let i = 0; i < session.sampleHotspotIds.length; i++) { + if (session.sampleHotspotIds[i] !== hotspotId) continue; + const timestampMs = session.sampleTimestampsMs[i] ?? 0; + const bucketIndex = Math.min(Math.floor(timestampMs / bucketWidthMs), bucketCount - 1); + counts[bucketIndex]++; + } + + return counts.flatMap((sampleCount, index) => { + if (sampleCount === 0) return []; + + return [{ + startMs: Math.round(index * bucketWidthMs), + endMs: Math.round((index + 1) * bucketWidthMs), + sampleCount, + percentOfHotspotSamples: round1(hotspotSamples > 0 ? (sampleCount / hotspotSamples) * 100 : 0), + }]; + }); +} + export interface ModulesOptions { sessionId?: string; limit?: number; diff --git a/packages/agent-cdp/src/js-profiler/types.ts b/packages/agent-cdp/src/js-profiler/types.ts index c50af53..8363376 100644 --- a/packages/agent-cdp/src/js-profiler/types.ts +++ b/packages/agent-cdp/src/js-profiler/types.ts @@ -112,6 +112,7 @@ export interface JsProfileSession { timeBuckets: JsTimeBucket[]; sampleTimestampsMs: number[]; sampleHotspotIds: (string | null)[]; + rawNodeToFrameId: Map; rawProfile: unknown; sourceMaps: SourceMapsInfo; } @@ -205,8 +206,10 @@ export interface JsHotspotDetailResult { hotspotId: string; selfTimeMs: number; totalTimeMs: number; + delegatedTimeMs: number; selfPercent: number; totalPercent: number; + delegatedPercentOfTotal: number; selfSampleCount: number; totalSampleCount: number; }; @@ -229,10 +232,32 @@ export interface JsHotspotDetailResult { percent: number; frames: string[]; }>; + occurrence: { + runCount: number; + averageRunSamples: number; + averageRunMs: number; + longestRunSamples: number; + longestRunMs: number; + firstSeenMs: number | null; + lastSeenMs: number | null; + }; + callers: Array<{ + functionName: string; + module: string; + sampleCount: number; + percent: number; + }>; + callees: Array<{ + functionName: string; + module: string; + sampleCount: number; + percent: number; + }>; activeTimeBuckets: Array<{ startMs: number; endMs: number; sampleCount: number; + percentOfHotspotSamples: number; }>; caveats: string[]; } diff --git a/packages/agent-cdp/src/source-maps.ts b/packages/agent-cdp/src/source-maps.ts index 828bed5..2bbec5f 100644 --- a/packages/agent-cdp/src/source-maps.ts +++ b/packages/agent-cdp/src/source-maps.ts @@ -125,8 +125,9 @@ export async function resolveSourceMapsForCandidates(candidates: Iterable { let text: string; const tailResponse = await fetch(bundleUrl, { diff --git a/packages/agent-cdp/src/types.ts b/packages/agent-cdp/src/types.ts index 603c41d..d037f49 100644 --- a/packages/agent-cdp/src/types.ts +++ b/packages/agent-cdp/src/types.ts @@ -145,7 +145,7 @@ export type IpcCommand = | { type: "js-profile-status" } | { type: "js-profile-list-sessions"; limit?: number; offset?: number } | { type: "js-profile-summary"; sessionId?: string } - | { type: "js-profile-hotspots"; sessionId?: string; limit?: number; offset?: number; sortBy?: string; minSelfMs?: number; includeRuntime?: boolean } + | { type: "js-profile-hotspots"; sessionId?: string; limit?: number; offset?: number; sortBy?: string; minSelfMs?: number; minTotalMs?: number; includeRuntime?: boolean } | { type: "js-profile-hotspot"; sessionId?: string; hotspotId: string; stackLimit?: number } | { type: "js-profile-modules"; sessionId?: string; limit?: number; offset?: number; sortBy?: string } | { type: "js-profile-stacks"; sessionId?: string; limit?: number; offset?: number; minMs?: number; maxDepth?: number }