diff --git a/packages/agent-cdp/README.md b/packages/agent-cdp/README.md index 779699f..cab2c1c 100644 --- a/packages/agent-cdp/README.md +++ b/packages/agent-cdp/README.md @@ -92,7 +92,7 @@ agent-cdp target clear - **Console** — list and fetch log lines: `console list`, `console get ` - **Network** — bounded live capture plus persisted sessions: `network status`, `network start`, `network summary`, `network list`, `network request`, `network request-headers`, `network response-headers`, `network request-body`, `network response-body` -- **Trace** — `trace start` / `trace stop [--file PATH]` for raw trace capture +- **Trace** — explicit trace capture plus in-memory session analysis for `performance.measure`, `performance.mark`, `console.timeStamp`, and custom DevTools tracks: `trace start`, `trace stop`, `trace summary`, `trace tracks`, `trace entries`, `trace entry` - **Memory (raw)** — `memory capture --file PATH` for a heap snapshot file - **Heap snapshot tools** — `mem-snapshot` commands to capture, load, summarize, diff snapshots, inspect classes/instances/retainers, and triage leak-style comparisons - **JS heap monitor** — `js-memory` commands for sampling, summaries, diffs, trends, and leak-oriented signals @@ -160,3 +160,56 @@ Current limitations: - WebSocket visibility is limited to handshake metadata in v1. - There is no throttling, blocking, mocking, replay, or HAR export in v1. - Timing, size, protocol, cache, and remote-endpoint metadata may be partial or absent depending on target behavior. + +## Trace inspection + +Use `trace` when you need to capture a bounded trace, then navigate the analyzed results in small, filterable chunks rather than dumping raw `traceEvents` into the terminal. + +Quick start: + +```sh +agent-cdp trace start +# reproduce the interaction you want to inspect +agent-cdp trace stop +agent-cdp trace summary +agent-cdp trace tracks +agent-cdp trace entries +agent-cdp trace entry --id te_1 +``` + +What the current trace tooling can inspect: + +- plain `performance.measure()` entries +- plain `performance.mark()` entries +- `console.timeStamp()` entries, including custom tracks and groups emitted through DevTools timeline events +- custom track and group metadata attached through DevTools-style `detail.devtools` payloads on user timing entries +- custom traces emitted by tools like React DevTools when they surface track-based timeline data through the trace stream + +Default behavior: + +- Tracing is explicit. The daemon does not start recording on startup; use `trace start` when you want a capture. +- `trace stop` stores an analyzed trace session in daemon memory so agents can query it immediately. +- `trace stop --file PATH` can still export the raw trace JSON when you need to inspect the underlying `traceEvents` directly. +- When `--session` is omitted, trace queries read from the latest analyzed session. +- `trace summary` gives a compact overview first. +- `trace tracks` lists discovered built-in and custom tracks with pagination/filtering. +- `trace entries` is the main drill-down command; it defaults to measures and supports `--track`, `--type`, `--text`, `--start-ms`, `--end-ms`, `--limit`, `--offset`, and `--sort`. +- `trace entry` shows the full details for exactly one selected entry. + +Examples: + +```sh +agent-cdp trace summary +agent-cdp trace tracks --group "Scheduler ⚛" +agent-cdp trace entries --track "Image Processing" --limit 10 +agent-cdp trace entries --type mark --text boot +agent-cdp trace entries --start-ms 0 --end-ms 100 --sort duration +agent-cdp trace entry --id te_16 +``` + +Current limitations: + +- Trace analysis is optimized for compact CLI inspection, not full flamechart rendering. +- Default track output reports active time on a track; use verbose output when you need the broader span between first and last entry. +- The parser handles common user timing and DevTools custom-track shapes seen in Chrome, React DevTools, and similar tools, but uncommon trace producers may still emit unsupported event forms. +- Trace sessions are stored in a bounded in-memory history rather than persisted automatically. diff --git a/packages/agent-cdp/skills/core.md b/packages/agent-cdp/skills/core.md index 88545d4..7760dee 100644 --- a/packages/agent-cdp/skills/core.md +++ b/packages/agent-cdp/skills/core.md @@ -91,13 +91,25 @@ That skill contains session behavior, common workflows, body inspection guidance ## Trace recording +For trace workflows, run: + +```bash +agent-cdp skills get trace +``` + +That skill contains trace session behavior, user-timing/custom-track inspection, token-efficient navigation guidance, and raw export guidance. + +Minimal commands: + ```bash agent-cdp trace start # begin recording a performance trace -agent-cdp trace stop [--file PATH] # stop and save (or auto-name) the trace +agent-cdp trace stop [--file PATH] # stop, analyze in memory, and optionally export the raw trace +agent-cdp trace summary +agent-cdp trace tracks +agent-cdp trace entries ``` -Produces a `.json` file loadable in Chrome DevTools Performance tab or -`chrome://tracing`. +Use `--file PATH` only when you need the raw trace JSON for direct inspection or external tools. ## Raw memory capture diff --git a/packages/agent-cdp/skills/trace.md b/packages/agent-cdp/skills/trace.md new file mode 100644 index 0000000..259a7f9 --- /dev/null +++ b/packages/agent-cdp/skills/trace.md @@ -0,0 +1,166 @@ +--- +name: trace +description: Trace inspection workflows for agent-cdp. Use after reading the core skill and selecting a target. Covers explicit trace capture, in-memory trace sessions, user timing inspection, custom DevTools tracks, token-efficient navigation, and raw trace export when needed. +allowed-tools: Bash(agent-cdp:*) +--- + +# agent-cdp trace + +Focused guide for trace capture and inspection after the daemon is running and a target has been selected. + +Prerequisite: + +```bash +agent-cdp skills get core +agent-cdp start +agent-cdp target list --url URL +agent-cdp target select --url URL +``` + +## Mental model + +- Tracing is explicit. The daemon does not start recording on startup. +- `trace start` begins a raw CDP trace capture for the selected target. +- `trace stop` ends capture, analyzes the trace in memory, and stores a queryable trace session in the daemon. +- `trace stop --file PATH` also exports the raw `traceEvents` JSON if you need the underlying trace file. +- Without `--session`, trace queries read from the latest analyzed session. +- Default trace output is intentionally compact so agents can navigate the data in chunks. + +## Commands + +```bash +agent-cdp trace start +agent-cdp trace stop [--file PATH] + +agent-cdp trace status +agent-cdp trace list [--limit N] [--offset N] +agent-cdp trace summary [--session ID] +agent-cdp trace tracks [--session ID] [--limit N] [--offset N] [--text TEXT] [--group NAME] +agent-cdp trace entries [--session ID] [--track NAME] [--type measure|mark|stamp] [--text TEXT] [--start-ms N] [--end-ms N] [--limit N] [--offset N] [--sort time|duration|name] +agent-cdp trace entry --id ENTRY_ID [--session ID] +``` + +## What trace analysis can inspect + +- plain `performance.measure()` entries +- plain `performance.mark()` entries +- `console.timeStamp()` entries +- custom track and group metadata attached through DevTools-style `detail.devtools` payloads on user timing entries +- custom tracks emitted through `devtools.timeline` `TimeStamp` events, including React DevTools timeline data + +## Workflow: Capture And Inspect A Fresh Trace + +```bash +agent-cdp trace start +# reproduce the interaction you want to inspect +agent-cdp trace stop +agent-cdp trace summary +agent-cdp trace tracks +agent-cdp trace entries +``` + +Use this when you want the latest interaction summarized without dumping raw trace JSON into the terminal. + +## Workflow: Navigate In Small Chunks + +Start broad, then narrow. + +```bash +agent-cdp trace summary +agent-cdp trace tracks --limit 10 +agent-cdp trace entries --limit 10 +agent-cdp trace entry --id te_1 +``` + +This is the preferred agent loop because it minimizes tokens while preserving drill-down. + +## Workflow: Focus On A Specific Track + +```bash +agent-cdp trace tracks --group "Scheduler ⚛" +agent-cdp trace entries --track "Blocking" --limit 10 +agent-cdp trace entries --track "Image Processing" --sort duration +``` + +Use `trace tracks` to discover the exact track names first, then filter `trace entries` by `--track`. + +## Workflow: Focus On A Specific Entry Type + +```bash +agent-cdp trace entries --type measure --limit 20 +agent-cdp trace entries --type mark --text boot +agent-cdp trace entries --type stamp --track "Console Track" +``` + +Guidance: +- `measure` is usually the best default when triaging performance work +- `mark` is useful for lifecycle waypoints and custom markers +- `stamp` is useful for DevTools-style custom timeline entries + +## Workflow: Time-Window Inspection + +```bash +agent-cdp trace entries --start-ms 0 --end-ms 100 --sort duration +agent-cdp trace entries --track "Blocking" --start-ms 100 --end-ms 250 +``` + +Use time windows to cut down noisy sessions before drilling into a specific entry id. + +## Workflow: Inspect One Entry Fully + +```bash +agent-cdp trace entries --track "Image Processing" --limit 5 +agent-cdp trace entry --id te_16 +``` + +`trace entry` is where you should expect to see the richest details such as tooltip text, custom properties, and any preserved user detail payload. + +## Workflow: Export The Raw Trace + +Use raw export only when the analyzed views do not answer the question or when you need to compare exact event shapes. + +```bash +agent-cdp trace start +# reproduce the interaction +agent-cdp trace stop --file /tmp/trace.json +``` + +The exported file can be inspected directly or loaded in tools that understand Chrome trace JSON. + +## Token-Efficient Navigation Tips + +- Prefer `trace summary` before any list command. +- Use `trace tracks` to discover candidate tracks before scanning entries. +- Use `--limit` and `--offset` on `trace list`, `trace tracks`, and `trace entries`. +- Use `--text`, `--type`, `--track`, `--start-ms`, and `--end-ms` to narrow the result set before printing. +- Use `trace entry --id ...` for full detail on a single item rather than increasing list limits. + +## Output Semantics + +- `trace summary` reports a compact session overview with entry counts and top tracks. +- `trace tracks` reports active time by default, not the broader span between the first and last entry on that track. +- `trace tracks --verbose` includes the broader track span in addition to active time. +- `trace entries` defaults to measures for a narrower, more actionable list. + +## Caveats + +- Trace analysis is optimized for CLI summaries and drill-down, not flamechart rendering. +- Support is strongest for common user timing and DevTools custom-track shapes seen in Chrome and React DevTools. +- Some trace producers may emit unsupported event forms. +- Trace sessions are kept in a bounded in-memory history rather than persisted automatically. + +## Suggested Agent Loop + +When debugging a performance issue, prefer this order: + +```bash +agent-cdp trace start +# reproduce the issue +agent-cdp trace stop +agent-cdp trace summary +agent-cdp trace tracks --limit 10 +agent-cdp trace entries --track TRACK_NAME --limit 10 +agent-cdp trace entry --id ENTRY_ID +``` + +Only export the raw file when the analyzed commands are not enough. diff --git a/packages/agent-cdp/src/__tests__/cli.test.ts b/packages/agent-cdp/src/__tests__/cli.test.ts index 02b0fc5..2982459 100644 --- a/packages/agent-cdp/src/__tests__/cli.test.ts +++ b/packages/agent-cdp/src/__tests__/cli.test.ts @@ -23,6 +23,8 @@ describe("cli", () => { expect(usage()).toContain("target select [--url URL]"); expect(usage()).toContain("network start [--name NAME] [--preserve-across-navigation]"); expect(usage()).toContain("network response-body --id REQ_ID [--session ID] [--file PATH]"); + expect(usage()).toContain("trace status"); + expect(usage()).toContain("trace entries [--session ID] [--track NAME]"); expect(usage()).toContain("js-allocation start"); expect(usage()).toContain("js-allocation-timeline start"); }); diff --git a/packages/agent-cdp/src/__tests__/trace-analysis.test.ts b/packages/agent-cdp/src/__tests__/trace-analysis.test.ts new file mode 100644 index 0000000..2df973c --- /dev/null +++ b/packages/agent-cdp/src/__tests__/trace-analysis.test.ts @@ -0,0 +1,366 @@ +import { TraceManager } from "../trace/index.js"; +import type { CdpEventMessage, CdpTransport, RuntimeSession, TargetDescriptor } from "../types.js"; + +class FakeTraceTransport implements CdpTransport { + private listener: ((message: CdpEventMessage) => void) | null = null; + + connect(): Promise { + return Promise.resolve(); + } + + disconnect(): Promise { + return Promise.resolve(); + } + + isConnected(): boolean { + return true; + } + + send(): Promise { + return Promise.resolve(undefined); + } + + onEvent(listener: (message: CdpEventMessage) => void): () => void { + this.listener = listener; + return () => { + this.listener = null; + }; + } + + emit(message: CdpEventMessage): void { + this.listener?.(message); + } +} + +function createTraceSession(transport: CdpTransport): RuntimeSession { + return { + target: { + id: "chrome:test:page-1", + rawId: "page-1", + title: "Example", + kind: "chrome", + description: "Test page", + webSocketDebuggerUrl: "ws://example.test/devtools/page/1", + sourceUrl: "http://example.test", + } satisfies TargetDescriptor, + transport, + ensureConnected: () => Promise.resolve(), + close: () => Promise.resolve(), + }; +} + +describe("TraceManager", () => { + it("stores analyzed sessions and exposes filtered entries and tracks", async () => { + const transport = new FakeTraceTransport(); + const manager = new TraceManager(); + + await manager.start(createTraceSession(transport)); + transport.emit({ + method: "Tracing.dataCollected", + params: { + value: [ + { + name: "Image Processing Complete", + cat: "blink.user_timing", + ph: "X", + ts: 1_000, + dur: 5_000, + args: { + data: { + beginEvent: { + args: { + detail: JSON.stringify({ + devtools: { + track: "Image Processing", + trackGroup: "My Group", + color: "tertiary-dark", + tooltipText: "Image processed successfully", + properties: [["Filter", "Gaussian Blur"]], + }, + }), + }, + }, + }, + }, + }, + { + name: "Render pass", + cat: "blink.user_timing", + ph: "X", + ts: 8_000, + dur: 2_000, + args: { data: {} }, + }, + { + name: "Hydration done", + cat: "blink.user_timing", + ph: "R", + ts: 12_000, + args: { + data: { + detail: JSON.stringify({ stage: "hydrate" }), + }, + }, + }, + { + name: "TimeStamp", + cat: "devtools.timeline", + ph: "I", + ts: 15_000, + args: { + data: { + name: "ts-start", + message: "ts-start", + }, + }, + }, + { + name: "TimeStamp", + cat: "devtools.timeline", + ph: "I", + ts: 18_000, + args: { + data: { + name: "ts-range", + message: "ts-range", + start: "ts-start", + track: "Console Track", + trackGroup: "Console Group", + color: "secondary", + }, + }, + }, + ], + }, + }); + + const stopPromise = manager.stop(); + transport.emit({ method: "Tracing.tracingComplete", params: {} }); + const stop = await stopPromise; + + expect(stop.eventCount).toBe(5); + expect(stop.entryCount).toBe(5); + expect(stop.trackCount).toBe(3); + + const summary = manager.getSummary(stop.sessionId); + expect(summary.entryCounts.measure).toBe(2); + expect(summary.entryCounts.mark).toBe(1); + expect(summary.entryCounts.stamp).toBe(2); + + const tracks = manager.getTracks({ sessionId: stop.sessionId, limit: 10, offset: 0 }); + expect(tracks.items.map((track) => track.name)).toEqual(expect.arrayContaining(["Timings", "Image Processing", "Console Track"])); + + const entries = manager.getEntries({ sessionId: stop.sessionId, track: "Image Processing", limit: 10, offset: 0 }); + expect(entries.total).toBe(1); + expect(entries.items[0]?.name).toBe("Image Processing Complete"); + expect(entries.items[0]?.trackGroup).toBe("My Group"); + + const consoleEntries = manager.getEntries({ sessionId: stop.sessionId, type: "stamp", track: "Console Track", limit: 10, offset: 0 }); + expect(consoleEntries.items[0]?.durationMs).toBe(3); + + const entry = manager.getEntry(entries.items[0]!.entryId, stop.sessionId); + expect(entry.properties).toEqual([["Filter", "Gaussian Blur"]]); + expect(entry.tooltipText).toBe("Image processed successfully"); + }); + + it("parses structured performance detail payloads for custom tracks", async () => { + const transport = new FakeTraceTransport(); + const manager = new TraceManager(); + + await manager.start(createTraceSession(transport)); + transport.emit({ + method: "Tracing.dataCollected", + params: { + value: [ + { + name: "Structured detail measure", + cat: "blink.user_timing", + ph: "X", + ts: 5_000, + dur: 4_000, + args: { + data: { + detail: { + devtools: { + track: "Structured Track", + trackGroup: "Structured Group", + color: "primary-dark", + tooltipText: "Structured tooltip", + properties: [["Kind", "object"]], + }, + stage: "measure", + }, + }, + }, + }, + ], + }, + }); + + const stopPromise = manager.stop(); + transport.emit({ method: "Tracing.tracingComplete", params: {} }); + const stop = await stopPromise; + + const entries = manager.getEntries({ sessionId: stop.sessionId, track: "Structured Track", limit: 10, offset: 0 }); + expect(entries.total).toBe(1); + expect(entries.items[0]?.trackGroup).toBe("Structured Group"); + expect(entries.items[0]?.color).toBe("primary-dark"); + + const entry = manager.getEntry(entries.items[0]!.entryId, stop.sessionId); + expect(entry.tooltipText).toBe("Structured tooltip"); + expect(entry.properties).toEqual([["Kind", "object"]]); + expect(entry.userDetail).toEqual({ stage: "measure" }); + }); + + it("parses raw blink.user_timing detail payloads from top-level args", async () => { + const transport = new FakeTraceTransport(); + const manager = new TraceManager(); + + await manager.start(createTraceSession(transport)); + transport.emit({ + method: "Tracing.dataCollected", + params: { + value: [ + { + name: "image-processing", + cat: "blink.user_timing", + id: "0x4", + ph: "b", + ts: 13_704_695_270, + args: { + detail: + '{"devtools":{"properties":[["Filter","Gaussian Blur"]],"tooltipText":"Image processed successfully","color":"tertiary-dark","trackGroup":"Demo","track":"Image Processing"}}', + }, + }, + { + name: "image-processing", + cat: "blink.user_timing", + id: "0x4", + ph: "e", + ts: 13_704_700_270, + args: { + detail: + '{"devtools":{"properties":[["Filter","Gaussian Blur"]],"tooltipText":"Image processed successfully","color":"tertiary-dark","trackGroup":"Demo","track":"Image Processing"}}', + }, + }, + ], + }, + }); + + const stopPromise = manager.stop(); + transport.emit({ method: "Tracing.tracingComplete", params: {} }); + const stop = await stopPromise; + + const entries = manager.getEntries({ sessionId: stop.sessionId, track: "Image Processing", limit: 10, offset: 0 }); + expect(entries.total).toBe(1); + expect(entries.items[0]?.trackGroup).toBe("Demo"); + expect(entries.items[0]?.color).toBe("tertiary-dark"); + expect(entries.items[0]?.tooltipText).toBe("Image processed successfully"); + expect(entries.items[0]?.properties).toEqual([["Filter", "Gaussian Blur"]]); + }); + + it("falls back to begin-event metadata for paired measures and parses devtools timeline timestamps", async () => { + const transport = new FakeTraceTransport(); + const manager = new TraceManager(); + + await manager.start(createTraceSession(transport)); + transport.emit({ + method: "Tracing.dataCollected", + params: { + value: [ + { + name: "image-processing", + cat: "blink.user_timing", + id: "0x4", + ph: "b", + ts: 10_000, + args: { + detail: + '{"devtools":{"properties":[["Filter","Gaussian Blur"]],"tooltipText":"Image processed successfully","color":"tertiary-dark","trackGroup":"Demo","track":"Image Processing"}}', + }, + }, + { + name: "image-processing", + cat: "blink.user_timing", + id: "0x4", + ph: "e", + ts: 12_000, + args: {}, + }, + { + name: "TimeStamp", + cat: "devtools.timeline", + ph: "I", + ts: 15_000, + args: { + data: { + name: "console-range", + message: "console-range", + start: 14_000, + end: 18_000, + track: "Console Track", + trackGroup: "Demo", + color: "primary", + }, + }, + }, + ], + }, + }); + + const stopPromise = manager.stop(); + transport.emit({ method: "Tracing.tracingComplete", params: {} }); + const stop = await stopPromise; + + const customMeasureEntries = manager.getEntries({ sessionId: stop.sessionId, track: "Image Processing", limit: 10, offset: 0 }); + expect(customMeasureEntries.total).toBe(1); + expect(customMeasureEntries.items[0]?.trackGroup).toBe("Demo"); + + const customStampEntries = manager.getEntries({ sessionId: stop.sessionId, track: "Console Track", type: "stamp", limit: 10, offset: 0 }); + expect(customStampEntries.total).toBe(1); + expect(customStampEntries.items[0]?.trackGroup).toBe("Demo"); + expect(customStampEntries.items[0]?.durationMs).toBe(4); + }); + + it("treats tiny numeric timestamp boundaries as registration markers", async () => { + const transport = new FakeTraceTransport(); + const manager = new TraceManager(); + + await manager.start(createTraceSession(transport)); + transport.emit({ + method: "Tracing.dataCollected", + params: { + value: [ + { + name: "TimeStamp", + cat: "devtools.timeline", + ph: "I", + ts: 13_704_644_808, + args: { + data: { + name: "Blocking Track", + message: "Blocking Track", + start: 3, + end: 3, + track: "Blocking", + trackGroup: "Scheduler ⚛", + color: "primary-light", + }, + }, + }, + ], + }, + }); + + const stopPromise = manager.stop(); + transport.emit({ method: "Tracing.tracingComplete", params: {} }); + const stop = await stopPromise; + + const tracks = manager.getTracks({ sessionId: stop.sessionId, limit: 10, offset: 0 }); + expect(tracks.items[0]?.name).toBe("Blocking"); + expect(tracks.items[0]?.activeMs).toBe(0); + expect(tracks.items[0]?.startMs).toBe(0); + expect(tracks.items[0]?.endMs).toBe(0); + expect(tracks.items[0]?.entryCount).toBe(1); + }); +}); diff --git a/packages/agent-cdp/src/cli.ts b/packages/agent-cdp/src/cli.ts index e46d52b..6599d33 100644 --- a/packages/agent-cdp/src/cli.ts +++ b/packages/agent-cdp/src/cli.ts @@ -8,8 +8,16 @@ import { formatMemorySummary, formatStatus, formatTargetList, - formatTraceSummary, } from "./formatters.js"; +import { + formatTraceEntries, + formatTraceEntry, + formatTraceSessionList, + formatTraceSessionSummary, + formatTraceStatus, + formatTraceStop, + formatTraceTracks, +} from "./trace/formatters.js"; import { formatNetworkBody, formatNetworkHeaders, @@ -71,13 +79,21 @@ import { formatJsStacks, } from "./js-profiler/formatters.js"; import type { MemSnapshotMeta } from "./heap-snapshot/types.js"; +import type { + TraceEntriesResult, + TraceEntry, + TraceSessionListEntry, + TraceStatusResult, + TraceStopResult, + TraceSummaryResult, + TraceTracksResult, +} from "./trace/types.js"; import type { ConsoleMessage, DiscoveryOptions, MemorySnapshotSummary, StatusInfo, TargetDescriptor, - TraceRecordingSummary, } from "./types.js"; export function usage(): string { @@ -117,6 +133,12 @@ Network: Trace: trace start trace stop [--file PATH] + trace status + trace list [--limit N] [--offset N] + trace summary [--session ID] + trace tracks [--session ID] [--limit N] [--offset N] [--text TEXT] [--group NAME] + trace entries [--session ID] [--track NAME] [--type measure|mark|stamp] [--text TEXT] [--start-ms N] [--end-ms N] [--limit N] [--offset N] [--sort time|duration|name] + trace entry --id ENTRY_ID [--session ID] Memory (raw capture): memory capture --file PATH @@ -239,14 +261,38 @@ function readConsoleMessage(data: unknown): ConsoleMessage { return data as ConsoleMessage; } -function readTraceSummary(data: unknown): TraceRecordingSummary { - return data as TraceRecordingSummary; -} - function readMemorySummary(data: unknown): MemorySnapshotSummary { return data as MemorySnapshotSummary; } +function readTraceStatus(data: unknown): TraceStatusResult { + return data as TraceStatusResult; +} + +function readTraceStop(data: unknown): TraceStopResult { + return data as TraceStopResult; +} + +function readTraceSessionList(data: unknown): TraceSessionListEntry[] { + return data as TraceSessionListEntry[]; +} + +function readTraceSessionSummary(data: unknown): TraceSummaryResult { + return data as TraceSummaryResult; +} + +function readTraceTracks(data: unknown): TraceTracksResult { + return data as TraceTracksResult; +} + +function readTraceEntries(data: unknown): TraceEntriesResult { + return data as TraceEntriesResult; +} + +function readTraceEntry(data: unknown): TraceEntry { + return data as TraceEntry; +} + function discoveryOptionsFromFlags(flags: Record): DiscoveryOptions { return { url: typeof flags.url === "string" ? flags.url : undefined, @@ -529,7 +575,86 @@ export async function main(): Promise { if (!response.ok) { throw new Error(response.error || "Failed to stop trace"); } - console.log(formatTraceSummary(readTraceSummary(response.data), verbose)); + console.log(formatTraceStop(readTraceStop(response.data), verbose)); + return; + } + + if (cmd === "trace" && command[1] === "status") { + await ensureDaemon(); + const response = await sendCommand({ type: "trace-status" }); + if (!response.ok) throw new Error(response.error || "Failed to get trace status"); + console.log(formatTraceStatus(readTraceStatus(response.data), verbose)); + return; + } + + if (cmd === "trace" && command[1] === "list") { + const limit = typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined; + const offset = typeof flags.offset === "string" ? Number.parseInt(flags.offset, 10) : undefined; + await ensureDaemon(); + const response = await sendCommand({ type: "trace-list-sessions", limit, offset }); + if (!response.ok) throw new Error(response.error || "Failed to list trace sessions"); + console.log(formatTraceSessionList(readTraceSessionList(response.data), verbose)); + return; + } + + if (cmd === "trace" && command[1] === "summary") { + const sessionId = typeof flags.session === "string" ? flags.session : undefined; + await ensureDaemon(); + const response = await sendCommand({ type: "trace-summary", sessionId }); + if (!response.ok) throw new Error(response.error || "Failed to get trace summary"); + console.log(formatTraceSessionSummary(readTraceSessionSummary(response.data), verbose)); + return; + } + + if (cmd === "trace" && command[1] === "tracks") { + const sessionId = typeof flags.session === "string" ? flags.session : undefined; + const limit = typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined; + const offset = typeof flags.offset === "string" ? Number.parseInt(flags.offset, 10) : undefined; + const text = typeof flags.text === "string" ? flags.text : undefined; + const group = typeof flags.group === "string" ? flags.group : undefined; + await ensureDaemon(); + const response = await sendCommand({ type: "trace-tracks", sessionId, limit, offset, text, group }); + if (!response.ok) throw new Error(response.error || "Failed to get trace tracks"); + console.log(formatTraceTracks(readTraceTracks(response.data), verbose)); + return; + } + + if (cmd === "trace" && command[1] === "entries") { + const sessionId = typeof flags.session === "string" ? flags.session : undefined; + const track = typeof flags.track === "string" ? flags.track : undefined; + const typeFilter = typeof flags.type === "string" ? flags.type : "measure"; + const text = typeof flags.text === "string" ? flags.text : undefined; + const startMs = typeof flags["start-ms"] === "string" ? Number.parseFloat(flags["start-ms"]) : undefined; + const endMs = typeof flags["end-ms"] === "string" ? Number.parseFloat(flags["end-ms"]) : undefined; + const limit = typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined; + const offset = typeof flags.offset === "string" ? Number.parseInt(flags.offset, 10) : undefined; + const sortBy = typeof flags.sort === "string" ? flags.sort : undefined; + await ensureDaemon(); + const response = await sendCommand({ + type: "trace-entries", + sessionId, + track, + typeFilter: typeFilter as "measure" | "mark" | "stamp", + text, + startMs, + endMs, + limit, + offset, + sortBy: sortBy as "time" | "duration" | "name" | undefined, + }); + if (!response.ok) throw new Error(response.error || "Failed to get trace entries"); + console.log(formatTraceEntries(readTraceEntries(response.data), verbose)); + return; + } + + if (cmd === "trace" && command[1] === "entry") { + const sessionId = typeof flags.session === "string" ? flags.session : undefined; + const entryId = typeof flags.id === "string" ? flags.id : undefined; + if (!entryId) throw new Error("Usage: agent-cdp trace entry --id ENTRY_ID [--session ID]"); + await ensureDaemon(); + const response = await sendCommand({ type: "trace-entry", sessionId, entryId }); + if (!response.ok) throw new Error(response.error || "Failed to get trace entry"); + console.log(formatTraceEntry(readTraceEntry(response.data), verbose)); return; } diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index bd8e1f4..f744730 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -12,7 +12,7 @@ import { MemorySnapshotter } from "./memory.js"; import { NetworkManager } from "./network/index.js"; import { createTargetProviders } from "./providers.js"; import { SessionManager } from "./session-manager.js"; -import { TraceRecorder } from "./trace.js"; +import { TraceManager } from "./trace/index.js"; import type { DaemonInfo, IpcCommand, IpcResponse, StatusInfo } from "./types.js"; import { getPackageVersion } from "./version.js"; @@ -52,7 +52,7 @@ class Daemon { private readonly jsHeapUsageMonitor = new JsHeapUsageMonitor(); private readonly providers = createTargetProviders(); private readonly sessionManager = new SessionManager(this.providers); - private readonly traceRecorder = new TraceRecorder(); + private readonly traceManager = new TraceManager(); private readonly jsProfiler = new JsProfiler(); private ipcServer: net.Server | null = null; @@ -261,12 +261,58 @@ class Daemon { if (command.type === "start-trace") { const session = await this.requireSession(); - await this.traceRecorder.start(session); + await this.traceManager.start(session); return { ok: true, data: "Trace started" }; } if (command.type === "stop-trace") { - return { ok: true, data: await this.traceRecorder.stop(command.filePath) }; + return { ok: true, data: await this.traceManager.stop(command.filePath) }; + } + + if (command.type === "trace-status") { + return { ok: true, data: this.traceManager.getStatus() }; + } + + if (command.type === "trace-list-sessions") { + return { ok: true, data: this.traceManager.listSessions(command.limit, command.offset) }; + } + + if (command.type === "trace-summary") { + return { ok: true, data: this.traceManager.getSummary(command.sessionId) }; + } + + if (command.type === "trace-tracks") { + return { + ok: true, + data: this.traceManager.getTracks({ + sessionId: command.sessionId, + limit: command.limit, + offset: command.offset, + text: command.text, + group: command.group, + }), + }; + } + + if (command.type === "trace-entries") { + return { + ok: true, + data: this.traceManager.getEntries({ + sessionId: command.sessionId, + track: command.track, + type: command.typeFilter, + text: command.text, + startMs: command.startMs, + endMs: command.endMs, + limit: command.limit, + offset: command.offset, + sortBy: command.sortBy, + }), + }; + } + + if (command.type === "trace-entry") { + return { ok: true, data: this.traceManager.getEntry(command.entryId, command.sessionId) }; } if (command.type === "capture-memory") { @@ -613,7 +659,7 @@ class Daemon { selectedTarget: this.sessionManager.getSelectedTarget(), providerCount: this.providers.length, sessionState: this.sessionManager.getSessionState(), - tracingActive: this.traceRecorder.isActive(), + tracingActive: this.traceManager.isActive(), }; return { ok: true, data: status }; diff --git a/packages/agent-cdp/src/trace.ts b/packages/agent-cdp/src/trace.ts index 51e0dea..fc8a4b2 100644 --- a/packages/agent-cdp/src/trace.ts +++ b/packages/agent-cdp/src/trace.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { CdpEventMessage, RuntimeSession, TraceRecordingSummary } from "./types.js"; +import type { CdpEventMessage, RuntimeSession } from "./types.js"; +import type { RawTraceEvent } from "./trace/types.js"; const TRACE_CATEGORIES = [ "-*", @@ -18,11 +19,20 @@ const TRACE_CATEGORIES = [ ]; interface ActiveTrace { - events: unknown[]; + events: RawTraceEvent[]; session: RuntimeSession; unsubscribe: () => void; resolveCompletion: () => void; completion: Promise; + startedAt: number; +} + +export interface TraceRecordingResult { + eventCount: number; + filePath?: string; + startedAt: number; + stoppedAt: number; + events: RawTraceEvent[]; } export class TraceRecorder { @@ -48,6 +58,7 @@ export class TraceRecorder { unsubscribe: () => undefined, resolveCompletion, completion, + startedAt: Date.now(), }; activeTrace.unsubscribe = session.transport.onEvent((message) => { @@ -66,7 +77,7 @@ export class TraceRecorder { } } - async stop(filePath?: string): Promise { + async stop(filePath?: string): Promise { if (!this.activeTrace) { throw new Error("No active trace to stop"); } @@ -77,6 +88,7 @@ export class TraceRecorder { await activeTrace.session.transport.send("Tracing.end"); await activeTrace.completion; activeTrace.unsubscribe(); + const stoppedAt = Date.now(); const outputPath = filePath ? path.resolve(filePath) : undefined; if (outputPath) { @@ -86,9 +98,16 @@ export class TraceRecorder { return { eventCount: activeTrace.events.length, filePath: outputPath, + startedAt: activeTrace.startedAt, + stoppedAt, + events: [...activeTrace.events], }; } + getElapsedMs(): number | null { + return this.activeTrace ? Date.now() - this.activeTrace.startedAt : null; + } + private handleEvent(activeTrace: ActiveTrace, message: CdpEventMessage): void { if (message.method === "Tracing.dataCollected") { const events = Array.isArray(message.params?.value) ? message.params.value : []; diff --git a/packages/agent-cdp/src/trace/extensions.ts b/packages/agent-cdp/src/trace/extensions.ts new file mode 100644 index 0000000..5752651 --- /dev/null +++ b/packages/agent-cdp/src/trace/extensions.ts @@ -0,0 +1,129 @@ +import type { RawTraceEvent } from "./types.js"; + +export interface DevtoolsExtensionData { + track?: string; + trackGroup?: string; + color?: string; + tooltipText?: string; + properties?: Array<[string, string]>; + dataType?: string; +} + +export interface ParsedExtensionData { + devtools: DevtoolsExtensionData | null; + userDetail: unknown; +} + +export function parsePerformanceExtensionData(event: RawTraceEvent): ParsedExtensionData { + const detail = readPerformanceDetail(event); + if (!detail) { + return { devtools: null, userDetail: null }; + } + + return parseDetailPayload(detail); +} + +export function parseConsoleExtensionData(event: RawTraceEvent): ParsedExtensionData { + const data = event.args?.data; + if (!isRecord(data) || !data.track) { + return { devtools: null, userDetail: null }; + } + + let userDetail: unknown = null; + if (typeof data.devtools === "string") { + try { + userDetail = JSON.parse(data.devtools); + } catch { + userDetail = null; + } + } + + return { + devtools: { + dataType: "track-entry", + track: String(data.track), + trackGroup: data.trackGroup === undefined ? undefined : String(data.trackGroup), + color: data.color === undefined ? undefined : String(data.color), + }, + userDetail, + }; +} + +function readPerformanceDetail(event: RawTraceEvent): string | Record | null { + const args = event.args; + if (isRecord(args)) { + if (typeof args.detail === "string" || isRecord(args.detail)) { + return args.detail; + } + } + + const data = args?.data; + if (!isRecord(data)) { + return null; + } + + if (typeof data.detail === "string" || isRecord(data.detail)) { + return data.detail; + } + + const beginEvent = data.beginEvent; + if (!isRecord(beginEvent)) { + return null; + } + + const beginArgs = beginEvent.args; + if (!isRecord(beginArgs) || (typeof beginArgs.detail !== "string" && !isRecord(beginArgs.detail))) { + return null; + } + + return beginArgs.detail; +} + +function parseDetailPayload(detail: string | Record): ParsedExtensionData { + try { + const parsed = typeof detail === "string" ? JSON.parse(detail) : detail; + if (!isRecord(parsed)) { + return { devtools: null, userDetail: null }; + } + + const devtools = isRecord(parsed.devtools) ? normalizeDevtoolsObject(parsed.devtools) : null; + const userDetail = { ...parsed }; + delete userDetail.devtools; + return { + devtools, + userDetail: Object.keys(userDetail).length > 0 ? userDetail : null, + }; + } catch { + return { devtools: null, userDetail: null }; + } +} + +function normalizeDevtoolsObject(devtools: Record): DevtoolsExtensionData { + return { + dataType: typeof devtools.dataType === "string" ? devtools.dataType : undefined, + track: typeof devtools.track === "string" ? devtools.track : undefined, + trackGroup: typeof devtools.trackGroup === "string" ? devtools.trackGroup : undefined, + color: typeof devtools.color === "string" ? devtools.color : undefined, + tooltipText: typeof devtools.tooltipText === "string" ? devtools.tooltipText : undefined, + properties: normalizeProperties(devtools.properties), + }; +} + +function normalizeProperties(properties: unknown): Array<[string, string]> | undefined { + if (!Array.isArray(properties)) { + return undefined; + } + + const normalized = properties.flatMap((entry) => { + if (!Array.isArray(entry) || entry.length < 2) { + return []; + } + return [[String(entry[0]), String(entry[1])] as [string, string]]; + }); + + return normalized.length > 0 ? normalized : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/agent-cdp/src/trace/formatters.ts b/packages/agent-cdp/src/trace/formatters.ts new file mode 100644 index 0000000..646400f --- /dev/null +++ b/packages/agent-cdp/src/trace/formatters.ts @@ -0,0 +1,129 @@ +import type { + TraceEntriesResult, + TraceEntry, + TraceSessionListEntry, + TraceStatusResult, + TraceStopResult, + TraceSummaryResult, + TraceTracksResult, +} from "./types.js"; + +function formatMs(ms: number): string { + return `${ms.toFixed(ms >= 100 ? 0 : ms >= 10 ? 1 : 2)}ms`; +} + +function truncate(text: string, max = 48): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} + +export function formatTraceStatus(result: TraceStatusResult, verbose = false): string { + if (verbose) { + const lines = [`Trace: ${result.active ? "active" : "idle"}`]; + if (result.elapsedMs !== null) { + lines.push(`Elapsed: ${(result.elapsedMs / 1000).toFixed(1)}s`); + } + lines.push(`Sessions: ${result.sessionCount}`); + return lines.join("\n"); + } + + const elapsed = result.elapsedMs !== null ? ` elapsed:${(result.elapsedMs / 1000).toFixed(1)}s` : ""; + return `${result.active ? "active" : "idle"}${elapsed} sessions:${result.sessionCount}`; +} + +export function formatTraceStop(result: TraceStopResult, verbose = false): string { + if (verbose) { + const lines = [ + `Trace session: ${result.sessionId}`, + `Entries: ${result.entryCount} | Tracks: ${result.trackCount} | Raw events: ${result.eventCount}`, + `Duration: ${formatMs(result.durationMs)}`, + ]; + if (result.filePath) lines.push(`Saved to: ${result.filePath}`); + return lines.join("\n"); + } + return `${result.sessionId} ${result.entryCount} entries ${result.trackCount} tracks ${result.eventCount} events` + + (result.filePath ? ` saved:${result.filePath}` : ""); +} + +export function formatTraceSessionList(entries: TraceSessionListEntry[], verbose = false): string { + if (entries.length === 0) return "No trace sessions"; + + return entries + .map((entry) => { + const date = new Date(entry.startedAt).toISOString().slice(0, 19).replace("T", " "); + if (verbose) { + return `${entry.sessionId} ${entry.name} ${formatMs(entry.durationMs)} ${entry.entryCount} entries ${entry.trackCount} tracks ${entry.eventCount} events ${date}`; + } + return `${entry.sessionId} ${formatMs(entry.durationMs)} ${entry.entryCount} entries ${entry.trackCount} tracks ${date}`; + }) + .join("\n"); +} + +export function formatTraceSessionSummary(summary: TraceSummaryResult, verbose = false): string { + const lines: string[] = []; + const session = summary.session; + lines.push( + `${session.sessionId} ${session.name} ${formatMs(session.durationMs)} ${session.entryCount} entries ${session.trackCount} tracks ${session.eventCount} events`, + ); + lines.push( + ` measure:${summary.entryCounts.measure} mark:${summary.entryCounts.mark} stamp:${summary.entryCounts.stamp} groups:${session.groupCount}`, + ); + for (const track of summary.topTracks) { + lines.push(` ${track.name} (${track.kind}) ${track.entryCount} entries` + (track.group ? ` group:${track.group}` : "")); + } + + if (verbose) { + lines.push(` started:${new Date(session.startedAt).toISOString()}`); + } + return lines.join("\n"); +} + +export function formatTraceTracks(result: TraceTracksResult, verbose = false): string { + if (result.items.length === 0) return "No trace tracks"; + return result.items + .map((track) => { + const base = `${track.name} ${track.kind} ${track.entryCount} entries ${formatMs(track.activeMs)}`; + if (!verbose) { + return track.group ? `${base} ${track.group}` : base; + } + return `${base} span:${formatMs(track.endMs - track.startMs)} measures:${track.measureCount} marks:${track.markCount} stamps:${track.stampCount}` + + (track.group ? ` group:${track.group}` : ""); + }) + .join("\n"); +} + +export function formatTraceEntries(result: TraceEntriesResult, verbose = false): string { + if (result.items.length === 0) return "No trace entries match the current filters."; + return result.items + .map((entry) => { + const base = `${entry.entryId} ${entry.type} ${formatMs(entry.durationMs)} ${entry.track} ${truncate(entry.name)}`; + if (!verbose) { + return base; + } + const extras = [`start:${formatMs(entry.startMs)}`, `source:${entry.source}`]; + if (entry.trackGroup) extras.push(`group:${entry.trackGroup}`); + if (entry.color) extras.push(`color:${entry.color}`); + return `${base} ${extras.join(" ")}`; + }) + .join("\n"); +} + +export function formatTraceEntry(entry: TraceEntry, verbose = false): string { + const lines = [ + `${entry.entryId} ${entry.type} ${entry.name}`, + `Track: ${entry.track}${entry.trackGroup ? ` (${entry.trackGroup})` : ""}`, + `Start: ${formatMs(entry.startMs)} | Duration: ${formatMs(entry.durationMs)} | Source: ${entry.source}`, + `Extension: ${entry.isExtension ? "yes" : "no"}`, + ]; + if (entry.color) lines.push(`Color: ${entry.color}`); + if (entry.tooltipText) lines.push(`Tooltip: ${entry.tooltipText}`); + if (entry.properties && entry.properties.length > 0) { + lines.push("Properties:"); + for (const [key, value] of entry.properties) { + lines.push(` ${key}: ${value}`); + } + } + if (verbose && entry.userDetail !== undefined && entry.userDetail !== null) { + lines.push(`Detail: ${JSON.stringify(entry.userDetail)}`); + } + return lines.join("\n"); +} diff --git a/packages/agent-cdp/src/trace/index.ts b/packages/agent-cdp/src/trace/index.ts new file mode 100644 index 0000000..dae481f --- /dev/null +++ b/packages/agent-cdp/src/trace/index.ts @@ -0,0 +1,101 @@ +import type { RuntimeSession } from "../types.js"; +import { TraceRecorder } from "../trace.js"; +import { normalizeTraceEvents } from "./load.js"; +import { + queryEntries, + queryEntry, + querySessions, + querySummary, + queryTracks, +} from "./query.js"; +import { TraceStore } from "./store.js"; +import type { + TraceEntriesResult, + TraceEntry, + TraceEntryFilters, + TraceSession, + TraceSessionListEntry, + TraceStatusResult, + TraceStopResult, + TraceSummaryResult, + TraceTrackFilters, + TraceTracksResult, +} from "./types.js"; +import { buildTraceEntries } from "./user-timings.js"; + +export class TraceManager { + private readonly recorder = new TraceRecorder(); + private readonly store = new TraceStore(); + + async start(session: RuntimeSession): Promise { + await this.recorder.start(session); + } + + async stop(filePath?: string): Promise { + const recording = await this.recorder.stop(filePath); + const sessionId = this.store.generateId(); + const normalizedTrace = normalizeTraceEvents(recording.events); + const analyzed = buildTraceEntries(normalizedTrace.events, normalizedTrace.originTs); + const session: TraceSession = { + sessionId, + name: `trace-${sessionId}`, + startedAt: recording.startedAt, + stoppedAt: recording.stoppedAt, + durationMs: analyzed.durationMs, + eventCount: recording.eventCount, + filePath: recording.filePath, + entries: analyzed.entries, + entriesById: new Map(analyzed.entries.map((entry) => [entry.entryId, entry])), + tracks: analyzed.tracks, + }; + this.store.add(session); + return { + sessionId, + eventCount: recording.eventCount, + filePath: recording.filePath, + trackCount: session.tracks.length, + entryCount: session.entries.length, + durationMs: session.durationMs, + }; + } + + isActive(): boolean { + return this.recorder.isActive(); + } + + getStatus(): TraceStatusResult { + return { + active: this.recorder.isActive(), + elapsedMs: this.recorder.getElapsedMs(), + sessionCount: this.store.count(), + }; + } + + listSessions(limit = 20, offset = 0): TraceSessionListEntry[] { + return querySessions(this.store.list(), limit, offset); + } + + getSummary(sessionId?: string): TraceSummaryResult { + return querySummary(this.resolveSession(sessionId)); + } + + getTracks(filters: TraceTrackFilters): TraceTracksResult { + return queryTracks(this.resolveSession(filters.sessionId), filters); + } + + getEntries(filters: TraceEntryFilters): TraceEntriesResult { + return queryEntries(this.resolveSession(filters.sessionId), filters); + } + + getEntry(entryId: string, sessionId?: string): TraceEntry { + return queryEntry(this.resolveSession(sessionId), entryId); + } + + private resolveSession(sessionId?: string): TraceSession { + const session = sessionId ? this.store.get(sessionId) : this.store.getLatest(); + if (!session) { + throw new Error(sessionId ? `Trace session ${sessionId} not found` : "No trace sessions available. Run trace start/stop first."); + } + return session; + } +} diff --git a/packages/agent-cdp/src/trace/load.ts b/packages/agent-cdp/src/trace/load.ts new file mode 100644 index 0000000..89882d7 --- /dev/null +++ b/packages/agent-cdp/src/trace/load.ts @@ -0,0 +1,29 @@ +import type { RawTraceEvent } from "./types.js"; + +export interface NormalizedTrace { + events: RawTraceEvent[]; + originTs: number; +} + +export function normalizeTraceEvents(rawEvents: unknown[]): NormalizedTrace { + const events = rawEvents.filter((event): event is RawTraceEvent => { + return typeof event === "object" && event !== null; + }); + + const profileEvent = events.find((event) => { + return event.name === "Profile" && isRecord(event.args) && isRecord(event.args.data); + }); + const profileData = isRecord(profileEvent?.args) && isRecord(profileEvent.args.data) ? profileEvent.args.data : undefined; + const originTs = + typeof profileData?.startTime === "number" + ? profileData.startTime + : typeof events[0]?.ts === "number" + ? events[0].ts + : 0; + + return { events, originTs }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/agent-cdp/src/trace/query.ts b/packages/agent-cdp/src/trace/query.ts new file mode 100644 index 0000000..be18d59 --- /dev/null +++ b/packages/agent-cdp/src/trace/query.ts @@ -0,0 +1,117 @@ +import type { + TraceEntriesResult, + TraceEntry, + TraceEntryFilters, + TraceSession, + TraceSessionListEntry, + TraceSummaryResult, + TraceTrackFilters, + TraceTracksResult, +} from "./types.js"; + +export function querySessions(sessions: TraceSession[], limit: number, offset: number): TraceSessionListEntry[] { + return sessions.slice(offset, offset + limit).map((session) => ({ + sessionId: session.sessionId, + name: session.name, + durationMs: Math.round(session.durationMs), + eventCount: session.eventCount, + entryCount: session.entries.length, + trackCount: session.tracks.length, + startedAt: session.startedAt, + })); +} + +export function querySummary(session: TraceSession): TraceSummaryResult { + return { + session: { + sessionId: session.sessionId, + name: session.name, + durationMs: Math.round(session.durationMs), + eventCount: session.eventCount, + entryCount: session.entries.length, + trackCount: session.tracks.length, + groupCount: new Set(session.tracks.map((track) => track.group).filter(Boolean)).size, + startedAt: session.startedAt, + }, + entryCounts: { + measure: session.entries.filter((entry) => entry.type === "measure").length, + mark: session.entries.filter((entry) => entry.type === "mark").length, + stamp: session.entries.filter((entry) => entry.type === "stamp").length, + }, + topTracks: session.tracks.slice(0, 5), + }; +} + +export function queryTracks(session: TraceSession, filters: TraceTrackFilters): TraceTracksResult { + const limit = filters.limit ?? 20; + const offset = filters.offset ?? 0; + let items = session.tracks; + + if (filters.group) { + items = items.filter((track) => track.group === filters.group); + } + if (filters.text) { + const needle = filters.text.toLowerCase(); + items = items.filter((track) => track.name.toLowerCase().includes(needle) || track.group?.toLowerCase().includes(needle)); + } + + return { + sessionId: session.sessionId, + total: items.length, + offset, + items: items.slice(offset, offset + limit), + }; +} + +export function queryEntries(session: TraceSession, filters: TraceEntryFilters): TraceEntriesResult { + const limit = filters.limit ?? 20; + const offset = filters.offset ?? 0; + const sortBy = filters.sortBy ?? "duration"; + let items = session.entries; + + if (filters.type) { + items = items.filter((entry) => entry.type === filters.type); + } + if (filters.track) { + items = items.filter((entry) => entry.track === filters.track); + } + if (filters.text) { + const needle = filters.text.toLowerCase(); + items = items.filter((entry) => { + return entry.name.toLowerCase().includes(needle) || entry.track.toLowerCase().includes(needle) || entry.trackGroup?.toLowerCase().includes(needle); + }); + } + if (filters.startMs !== undefined) { + items = items.filter((entry) => entry.startMs + entry.durationMs >= filters.startMs!); + } + if (filters.endMs !== undefined) { + items = items.filter((entry) => entry.startMs <= filters.endMs!); + } + + items = [...items].sort((a, b) => compareEntries(a, b, sortBy)); + + return { + sessionId: session.sessionId, + total: items.length, + offset, + items: items.slice(offset, offset + limit), + }; +} + +export function queryEntry(session: TraceSession, entryId: string): TraceEntry { + const entry = session.entriesById.get(entryId); + if (!entry) { + throw new Error(`Trace entry ${entryId} not found in session ${session.sessionId}`); + } + return entry; +} + +function compareEntries(a: TraceEntry, b: TraceEntry, sortBy: TraceEntryFilters["sortBy"]): number { + if (sortBy === "name") { + return a.name.localeCompare(b.name) || a.startMs - b.startMs; + } + if (sortBy === "time") { + return a.startMs - b.startMs || b.durationMs - a.durationMs; + } + return b.durationMs - a.durationMs || a.startMs - b.startMs; +} diff --git a/packages/agent-cdp/src/trace/store.ts b/packages/agent-cdp/src/trace/store.ts new file mode 100644 index 0000000..a25ee62 --- /dev/null +++ b/packages/agent-cdp/src/trace/store.ts @@ -0,0 +1,35 @@ +import type { TraceSession } from "./types.js"; + +const MAX_TRACE_SESSIONS = 10; + +export class TraceStore { + private sessions: TraceSession[] = []; + private nextId = 1; + + generateId(): string { + return `tr_${this.nextId++}`; + } + + add(session: TraceSession): void { + this.sessions.push(session); + if (this.sessions.length > MAX_TRACE_SESSIONS) { + this.sessions.splice(0, this.sessions.length - MAX_TRACE_SESSIONS); + } + } + + get(sessionId: string): TraceSession | undefined { + return this.sessions.find((session) => session.sessionId === sessionId); + } + + getLatest(): TraceSession | undefined { + return this.sessions.at(-1); + } + + list(): TraceSession[] { + return [...this.sessions].reverse(); + } + + count(): number { + return this.sessions.length; + } +} diff --git a/packages/agent-cdp/src/trace/types.ts b/packages/agent-cdp/src/trace/types.ts new file mode 100644 index 0000000..2d782f4 --- /dev/null +++ b/packages/agent-cdp/src/trace/types.ts @@ -0,0 +1,139 @@ +export type TraceEntryType = "measure" | "mark" | "stamp"; +export type TraceEntrySource = "performance" | "console"; +export type TraceTrackKind = "default" | "custom"; +export type TraceEntrySort = "time" | "duration" | "name"; + +export interface RawTraceEvent { + name?: string; + cat?: string; + ph?: string; + ts?: number; + dur?: number; + id?: string | number; + pid?: number; + tid?: number; + args?: Record; + [key: string]: unknown; +} + +export interface TraceEntry { + entryId: string; + type: TraceEntryType; + source: TraceEntrySource; + name: string; + track: string; + trackKind: TraceTrackKind; + trackGroup?: string; + startMs: number; + durationMs: number; + color?: string; + tooltipText?: string; + properties?: Array<[string, string]>; + userDetail?: unknown; + isExtension: boolean; +} + +export interface TraceTrack { + trackId: string; + name: string; + kind: TraceTrackKind; + group?: string; + entryCount: number; + measureCount: number; + markCount: number; + stampCount: number; + activeMs: number; + startMs: number; + endMs: number; +} + +export interface TraceSession { + sessionId: string; + name: string; + startedAt: number; + stoppedAt: number; + durationMs: number; + eventCount: number; + filePath?: string; + entries: TraceEntry[]; + entriesById: Map; + tracks: TraceTrack[]; +} + +export interface TraceStatusResult { + active: boolean; + elapsedMs: number | null; + sessionCount: number; +} + +export interface TraceStopResult { + sessionId: string; + eventCount: number; + filePath?: string; + trackCount: number; + entryCount: number; + durationMs: number; +} + +export interface TraceSessionListEntry { + sessionId: string; + name: string; + durationMs: number; + eventCount: number; + entryCount: number; + trackCount: number; + startedAt: number; +} + +export interface TraceSummaryResult { + session: { + sessionId: string; + name: string; + durationMs: number; + eventCount: number; + entryCount: number; + trackCount: number; + groupCount: number; + startedAt: number; + }; + entryCounts: { + measure: number; + mark: number; + stamp: number; + }; + topTracks: TraceTrack[]; +} + +export interface TraceTracksResult { + sessionId: string; + total: number; + offset: number; + items: TraceTrack[]; +} + +export interface TraceEntryFilters { + sessionId?: string; + track?: string; + type?: TraceEntryType; + text?: string; + startMs?: number; + endMs?: number; + limit?: number; + offset?: number; + sortBy?: TraceEntrySort; +} + +export interface TraceEntriesResult { + sessionId: string; + total: number; + offset: number; + items: TraceEntry[]; +} + +export interface TraceTrackFilters { + sessionId?: string; + group?: string; + text?: string; + limit?: number; + offset?: number; +} diff --git a/packages/agent-cdp/src/trace/user-timings.ts b/packages/agent-cdp/src/trace/user-timings.ts new file mode 100644 index 0000000..cf6b512 --- /dev/null +++ b/packages/agent-cdp/src/trace/user-timings.ts @@ -0,0 +1,312 @@ +import { parseConsoleExtensionData, parsePerformanceExtensionData } from "./extensions.js"; +import type { RawTraceEvent, TraceEntry, TraceTrack } from "./types.js"; + +const DEFAULT_TRACK = "Timings"; +const RESOURCE_TIMING_NAMES = new Set([ + "workerStart", + "redirectStart", + "redirectEnd", + "fetchStart", + "domainLookupStart", + "domainLookupEnd", + "connectStart", + "connectEnd", + "secureConnectionStart", + "requestStart", + "responseStart", + "responseEnd", + "navigationStart", + "unloadEventStart", + "unloadEventEnd", + "commitNavigationEnd", + "domLoading", + "domInteractive", + "domContentLoadedEventStart", + "domContentLoadedEventEnd", + "domComplete", + "loadEventStart", + "loadEventEnd", +]); + +export function buildTraceEntries( + events: RawTraceEvent[], + originTs: number, +): { entries: TraceEntry[]; tracks: TraceTrack[]; durationMs: number } { + const entries: TraceEntry[] = []; + const asyncStarts = new Map(); + const namedTimestamps = new Map(); + let nextEntryId = 1; + + for (const event of events) { + const category = typeof event.cat === "string" ? event.cat : ""; + if (category === "blink.user_timing") { + if (isIgnoredUserTiming(event)) { + continue; + } + + const completeEntry = createPerformanceTimingEntry(event, nextEntryId, originTs); + if (completeEntry) { + entries.push(completeEntry.entry); + nextEntryId = completeEntry.nextEntryId; + continue; + } + + const asyncKey = getAsyncEventKey(event); + if (!asyncKey) { + continue; + } + + if (event.ph === "b" || event.ph === "B") { + asyncStarts.set(asyncKey, event); + continue; + } + + if (event.ph === "e" || event.ph === "E") { + const start = asyncStarts.get(asyncKey); + if (!start) { + continue; + } + asyncStarts.delete(asyncKey); + const entry = createPairedPerformanceMeasure(start, event, nextEntryId++, originTs); + if (entry) { + entries.push(entry); + } + } + continue; + } + + if (category === "blink.console" || (category === "devtools.timeline" && event.name === "TimeStamp")) { + const entry = createConsoleTimestampEntry(event, namedTimestamps, nextEntryId, originTs); + if (entry) { + entries.push(entry.entry); + nextEntryId = entry.nextEntryId; + } + } + } + + const sortedEntries = [...entries].sort((a, b) => { + if (a.startMs !== b.startMs) { + return a.startMs - b.startMs; + } + if (a.durationMs !== b.durationMs) { + return b.durationMs - a.durationMs; + } + return a.entryId.localeCompare(b.entryId); + }); + + const tracks = buildTracks(sortedEntries); + const durationMs = + sortedEntries.length === 0 + ? 0 + : Math.max(...sortedEntries.map((entry) => entry.startMs + entry.durationMs)) - + Math.min(...sortedEntries.map((entry) => entry.startMs)); + return { entries: sortedEntries, tracks, durationMs }; +} + +function createPerformanceTimingEntry( + event: RawTraceEvent, + nextEntryId: number, + originTs: number, +): { entry: TraceEntry; nextEntryId: number } | null { + const phase = typeof event.ph === "string" ? event.ph : ""; + const hasDuration = typeof event.dur === "number" || phase === "X"; + const isInstant = phase === "I" || phase === "R" || phase === "i" || (!hasDuration && event.name === "performance.mark"); + + if (!hasDuration && !isInstant) { + return null; + } + + const { devtools, userDetail } = parsePerformanceExtensionData(event); + const type = hasDuration ? "measure" : "mark"; + const track = type === "measure" ? devtools?.track || DEFAULT_TRACK : devtools?.track || DEFAULT_TRACK; + + return { + entry: { + entryId: `te_${nextEntryId}`, + type, + source: "performance", + name: typeof event.name === "string" ? event.name : "(unnamed)", + track, + trackKind: devtools?.track ? "custom" : "default", + trackGroup: devtools?.trackGroup, + startMs: timestampToMs(event.ts, originTs), + durationMs: hasDuration ? microToMs(event.dur) : 0, + color: devtools?.color, + tooltipText: devtools?.tooltipText, + properties: devtools?.properties, + userDetail, + isExtension: devtools !== null, + }, + nextEntryId: nextEntryId + 1, + }; +} + +function createPairedPerformanceMeasure( + start: RawTraceEvent, + end: RawTraceEvent, + nextEntryId: number, + originTs: number, +): TraceEntry | null { + if (typeof start.ts !== "number" || typeof end.ts !== "number" || end.ts < start.ts) { + return null; + } + + const endData = parsePerformanceExtensionData(end); + const startData = parsePerformanceExtensionData(start); + const devtools = endData.devtools ?? startData.devtools; + const userDetail = endData.userDetail ?? startData.userDetail; + return { + entryId: `te_${nextEntryId}`, + type: "measure", + source: "performance", + name: typeof end.name === "string" ? end.name : typeof start.name === "string" ? start.name : "(unnamed)", + track: devtools?.track || DEFAULT_TRACK, + trackKind: devtools?.track ? "custom" : "default", + trackGroup: devtools?.trackGroup, + startMs: timestampToMs(start.ts, originTs), + durationMs: microToMs(end.ts - start.ts), + color: devtools?.color, + tooltipText: devtools?.tooltipText, + properties: devtools?.properties, + userDetail, + isExtension: devtools !== null, + }; +} + +function createConsoleTimestampEntry( + event: RawTraceEvent, + namedTimestamps: Map, + nextEntryId: number, + originTs: number, +): { entry: TraceEntry; nextEntryId: number } | null { + const data = event.args?.data; + if (!isRecord(data) || typeof event.ts !== "number") { + return null; + } + + namedTimestamps.set(readConsoleTimestampName(data), event); + const { devtools, userDetail } = parseConsoleExtensionData(event); + const startTs = resolveConsoleTimestampBoundary(data.start, namedTimestamps, event.ts) ?? event.ts; + const endTs = resolveConsoleTimestampBoundary(data.end, namedTimestamps, event.ts) ?? event.ts; + const startMs = timestampToMs(startTs, originTs); + const durationMs = Math.max(0, microToMs(endTs - startTs)); + + return { + entry: { + entryId: `te_${nextEntryId}`, + type: "stamp", + source: "console", + name: readConsoleTimestampName(data), + track: devtools?.track || DEFAULT_TRACK, + trackKind: devtools?.track ? "custom" : "default", + trackGroup: devtools?.trackGroup, + startMs, + durationMs, + color: devtools?.color, + tooltipText: devtools?.tooltipText, + properties: devtools?.properties, + userDetail, + isExtension: devtools !== null, + }, + nextEntryId: nextEntryId + 1, + }; +} + +function buildTracks(entries: TraceEntry[]): TraceTrack[] { + const tracks = new Map(); + + for (const entry of entries) { + const key = `${entry.trackKind}:${entry.trackGroup || ""}:${entry.track}`; + const existing = tracks.get(key); + if (existing) { + existing.entryCount += 1; + existing.measureCount += entry.type === "measure" ? 1 : 0; + existing.markCount += entry.type === "mark" ? 1 : 0; + existing.stampCount += entry.type === "stamp" ? 1 : 0; + existing.activeMs += entry.durationMs; + existing.startMs = Math.min(existing.startMs, entry.startMs); + existing.endMs = Math.max(existing.endMs, entry.startMs + entry.durationMs); + continue; + } + + tracks.set(key, { + trackId: `tt_${tracks.size + 1}`, + name: entry.track, + kind: entry.trackKind, + group: entry.trackGroup, + entryCount: 1, + measureCount: entry.type === "measure" ? 1 : 0, + markCount: entry.type === "mark" ? 1 : 0, + stampCount: entry.type === "stamp" ? 1 : 0, + activeMs: entry.durationMs, + startMs: entry.startMs, + endMs: entry.startMs + entry.durationMs, + }); + } + + return [...tracks.values()].sort((a, b) => { + if (a.entryCount !== b.entryCount) { + return b.entryCount - a.entryCount; + } + return a.name.localeCompare(b.name); + }); +} + +function getAsyncEventKey(event: RawTraceEvent): string | null { + if (typeof event.name !== "string") { + return null; + } + + const traceId = isRecord(event.args) && isRecord(event.args.data) && typeof event.args.data.traceId === "number" + ? event.args.data.traceId + : event.id; + + if (traceId === undefined || traceId === null) { + return null; + } + + return `${event.cat || ""}:${event.name}:${String(traceId)}`; +} + +function isIgnoredUserTiming(event: RawTraceEvent): boolean { + return typeof event.name === "string" && RESOURCE_TIMING_NAMES.has(event.name); +} + +function readConsoleTimestampName(data: Record): string { + if (typeof data.name === "string") { + return data.name; + } + if (typeof data.message === "string") { + return data.message; + } + return "console.timeStamp"; +} + +function resolveConsoleTimestampBoundary( + boundary: unknown, + namedTimestamps: Map, + eventTs: number, +): number | undefined { + if (typeof boundary === "number") { + if (boundary >= 0 && boundary < 1000) { + return eventTs; + } + return boundary; + } + if (typeof boundary === "string") { + return namedTimestamps.get(boundary)?.ts; + } + return undefined; +} + +function microToMs(value: unknown): number { + return typeof value === "number" ? Math.round((value / 1000) * 1000) / 1000 : 0; +} + +function timestampToMs(value: unknown, originTs: number): number { + return typeof value === "number" ? microToMs(value - originTs) : 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/agent-cdp/src/types.ts b/packages/agent-cdp/src/types.ts index a6e822b..e7a429c 100644 --- a/packages/agent-cdp/src/types.ts +++ b/packages/agent-cdp/src/types.ts @@ -124,6 +124,23 @@ export type IpcCommand = | { type: "network-response-body"; requestId: string; sessionId?: string; filePath?: string } | { type: "start-trace" } | { type: "stop-trace"; filePath?: string } + | { type: "trace-status" } + | { type: "trace-list-sessions"; limit?: number; offset?: number } + | { type: "trace-summary"; sessionId?: string } + | { type: "trace-tracks"; sessionId?: string; limit?: number; offset?: number; text?: string; group?: string } + | { + type: "trace-entries"; + sessionId?: string; + track?: string; + typeFilter?: "measure" | "mark" | "stamp"; + text?: string; + startMs?: number; + endMs?: number; + limit?: number; + offset?: number; + sortBy?: "time" | "duration" | "name"; + } + | { type: "trace-entry"; sessionId?: string; entryId: string } | { type: "capture-memory"; filePath: string } | { type: "js-profile-start"; name?: string; samplingIntervalUs?: number } | { type: "js-profile-stop" } diff --git a/trace-demo.sh b/trace-demo.sh new file mode 100755 index 0000000..d44d383 --- /dev/null +++ b/trace-demo.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TRACE_FILE="${TRACE_FILE:-/tmp/agent-cdp-trace-$(date +%s).json}" +STOPPED=0 + +print_section() { + printf '\n== %s ==\n' "$1" +} + +run_cmd() { + printf '+ %s\n' "$*" + "$@" +} + +show_latest_entry() { + local first_line entry_id + + first_line="$(pnpm agent-cdp trace entries --limit 1 2>/dev/null || true)" + if [[ -z "$first_line" ]]; then + return + fi + + IFS=' ' read -r entry_id _ <<< "$first_line" + if [[ -n "$entry_id" && "$entry_id" != "No" ]]; then + print_section "trace entry --id $entry_id" + run_cmd pnpm agent-cdp trace entry --id "$entry_id" + fi +} + +cleanup() { + if [[ "$STOPPED" -eq 1 ]]; then + return + fi + STOPPED=1 + + print_section "Stopping trace" + run_cmd pnpm agent-cdp trace stop --file "$TRACE_FILE" + + print_section "trace status" + run_cmd pnpm agent-cdp trace status + + print_section "trace list" + run_cmd pnpm agent-cdp trace list + + print_section "trace summary" + run_cmd pnpm agent-cdp trace summary + + print_section "trace tracks" + run_cmd pnpm agent-cdp trace tracks + + print_section "trace entries" + run_cmd pnpm agent-cdp trace entries + + show_latest_entry + + print_section "Raw trace file" + printf '%s\n' "$TRACE_FILE" +} + +trap cleanup INT TERM + +print_section "Starting trace" +run_cmd pnpm agent-cdp trace start + +printf '\nTrace is running. Press Ctrl+C to stop and inspect the latest session.\n' + +while true; do + sleep 1 +done