diff --git a/docs/PERFTRACE.md b/docs/PERFTRACE.md index 9709c3ef0..a2ff1188f 100644 --- a/docs/PERFTRACE.md +++ b/docs/PERFTRACE.md @@ -10,7 +10,8 @@ PostHog usage events. - In-process ring buffer of phase spans (turn, inference, tools, …) - Privacy-strict tags: enums, ids, and numbers only — no prompts, paths, tool args, free-text errors, or credentials -- Future session dumps (CL-5169) use the same allowlist and must never include +- Offline dumps: `dumpSpans` + `rollupByPhase` / `rollupByTurn` / `sessionTotals` + (`src/perf/dump.ts`, `src/perf/rollup.ts`) — same tag allowlist; never include OTEL auth headers Local measurement does not require any settings or env vars. diff --git a/src/perf/dump.ts b/src/perf/dump.ts new file mode 100644 index 000000000..fbb61a304 --- /dev/null +++ b/src/perf/dump.ts @@ -0,0 +1,168 @@ +/** + * Privacy-strict local dump of a PerfSpan snapshot. + * + * Writes compact JSON beside session artifacts. Re-sanitizes tags and strips + * any non-allowlisted shape so the file is safe to share offline. + * No network. + */ + +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + ALLOWED_TAG_KEYS, + type PerfSpan, + type PerfTags, + type SpanName, + sanitizeTags, +} from "./index.js"; +import { + rollupByPhase, + rollupByTurn, + sessionTotals, + type PhaseSummary, + type SessionTotals, + type TurnSummary, +} from "./rollup.js"; + +/** Dump schema version — bump when the on-disk shape changes incompatibly. */ +export const DUMP_VERSION = 1 as const; + +/** Allowlisted keys that may appear on a serialized span object. */ +export const DUMP_SPAN_KEYS = [ + "id", + "name", + "parentId", + "startNs", + "endNs", + "open", + "tags", +] as const; + +export type DumpSpan = { + id: string; + name: SpanName; + parentId?: string; + /** Absolute monotonic ns as decimal string (preserves bigint precision). */ + startNs: string; + endNs?: string; + /** Present and true when the span was still open at dump time. */ + open?: true; + tags?: PerfTags; +}; + +export type PerfDump = { + version: typeof DUMP_VERSION; + sessionId: string; + /** ISO-8601 wall clock when the dump was written (not span time). */ + writtenAt: string; + spanCount: number; + openCount: number; + rollup: { + byPhase: PhaseSummary[]; + byTurn: TurnSummary[]; + session: SessionTotals; + }; + spans: DumpSpan[]; +}; + +export type DumpOptions = { + /** Directory that already holds (or will hold) session artifacts. */ + dir: string; + /** Opaque session id — used only in the filename and dump header. */ + sessionId: string; +}; + +// Session ids in the product are opaque short strings; reject path traversal. +const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/; + +const ALLOWED_TAG_KEY_SET: ReadonlySet = new Set(ALLOWED_TAG_KEYS); + +function assertSafeSessionId(sessionId: string): void { + if (!SAFE_SESSION_ID_RE.test(sessionId)) { + throw new Error( + `dumpSpans: sessionId must be a short opaque id (got ${JSON.stringify(sessionId)})`, + ); + } +} + +/** + * Project a live PerfSpan onto the dump allowlist. + * Bigints become decimal strings; tags are re-sanitized. + */ +export function serializeSpan(span: PerfSpan): DumpSpan { + const out: DumpSpan = { + id: span.id, + name: span.name, + startNs: span.startNs.toString(), + }; + if (span.parentId !== undefined) { + out.parentId = span.parentId; + } + if (span.endNs === undefined) { + out.open = true; + } else { + out.endNs = span.endNs.toString(); + } + // Defense in depth: re-run the privacy fence even if the in-memory span + // somehow carried extra keys (e.g. test fixtures or future sinks). + const tags = sanitizeTags(span.tags as Record | undefined); + if (tags !== undefined) { + out.tags = tags; + } + return out; +} + +/** Build the dump document without touching the filesystem. */ +export function buildDump(spans: readonly PerfSpan[], sessionId: string, writtenAt: string): PerfDump { + assertSafeSessionId(sessionId); + const serialized = spans.map(serializeSpan); + let openCount = 0; + for (const s of serialized) { + if (s.open === true) openCount += 1; + } + return { + version: DUMP_VERSION, + sessionId, + writtenAt, + spanCount: serialized.length, + openCount, + rollup: { + byPhase: rollupByPhase(spans), + byTurn: rollupByTurn(spans), + session: sessionTotals(spans), + }, + spans: serialized, + }; +} + +/** + * Write `perftrace-{sessionId}.json` under `opts.dir`. + * Returns the absolute-or-relative path written. + */ +export async function dumpSpans( + spans: readonly PerfSpan[], + opts: DumpOptions, +): Promise { + assertSafeSessionId(opts.sessionId); + const dump = buildDump(spans, opts.sessionId, new Date().toISOString()); + const filePath = join(opts.dir, `perftrace-${opts.sessionId}.json`); + await mkdir(opts.dir, { recursive: true }); + // Compact single-line JSON keeps diffs and `jq` usage simple. + await writeFile(filePath, `${JSON.stringify(dump)}\n`, "utf8"); + return filePath; +} + +/** + * Walk a parsed dump and return every tag key that is not allowlisted. + * Used by the privacy fixture test; also handy for operator scripts. + */ +export function collectNonAllowlistedTagKeys(dump: PerfDump): string[] { + const bad: string[] = []; + for (const span of dump.spans) { + if (span.tags === undefined) continue; + for (const key of Object.keys(span.tags)) { + if (!ALLOWED_TAG_KEY_SET.has(key)) bad.push(key); + } + } + return bad; +} diff --git a/src/perf/rollup.test.ts b/src/perf/rollup.test.ts new file mode 100644 index 000000000..a1c5a2adf --- /dev/null +++ b/src/perf/rollup.test.ts @@ -0,0 +1,515 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ALLOWED_TAG_KEYS, + RING_CAPACITY, + clear, + end, + mark, + snapshot, + start, + type PerfSpan, +} from "./index.js"; +import { + DUMP_SPAN_KEYS, + buildDump, + collectNonAllowlistedTagKeys, + dumpSpans, + serializeSpan, +} from "./dump.js"; +import { + rollupByPhase, + rollupByTurn, + sessionTotals, + spanDurationNs, +} from "./rollup.js"; + +afterEach(() => { + clear(); +}); + +const ALLOWED_TAG_KEY_SET: ReadonlySet = new Set(ALLOWED_TAG_KEYS); +const DUMP_SPAN_KEY_SET: ReadonlySet = new Set(DUMP_SPAN_KEYS); + +/** Build a completed span with fixed times (no live clock). */ +function span( partial: { + id: string; + name: PerfSpan["name"]; + parentId?: string; + startNs: bigint; + endNs?: bigint; + tags?: PerfSpan["tags"]; +}): PerfSpan { + const s: PerfSpan = { + id: partial.id, + name: partial.name, + startNs: partial.startNs, + }; + if (partial.parentId !== undefined) s.parentId = partial.parentId; + if (partial.endNs !== undefined) s.endNs = partial.endNs; + if (partial.tags !== undefined) s.tags = partial.tags; + return s; +} + +/** + * Nested tree: + * turn t1 + * inference i1 1000ns + * inference.ttft 200ns + * inference.stream 800ns + * tool k1 300ns + * tool k2 100ns + */ +function nestedTurnFixture(): PerfSpan[] { + return [ + span({ id: "t1", name: "turn", startNs: 0n, endNs: 2000n }), + span({ + id: "i1", + name: "inference", + parentId: "t1", + startNs: 100n, + endNs: 1100n, + }), + span({ + id: "ttft1", + name: "inference.ttft", + parentId: "i1", + startNs: 100n, + endNs: 300n, + }), + span({ + id: "stream1", + name: "inference.stream", + parentId: "i1", + startNs: 300n, + endNs: 1100n, + }), + span({ + id: "k1", + name: "tool", + parentId: "t1", + startNs: 1200n, + endNs: 1500n, + tags: { tool_id: "read" }, + }), + span({ + id: "k2", + name: "tool", + parentId: "t1", + startNs: 1600n, + endNs: 1700n, + tags: { tool_id: "edit" }, + }), + ]; +} + +describe("spanDurationNs", () => { + test("returns end - start for completed spans", () => { + expect(spanDurationNs(span({ id: "a", name: "tool", startNs: 10n, endNs: 40n }))).toBe(30); + }); + + test("returns undefined for open spans", () => { + expect(spanDurationNs(span({ id: "a", name: "tool", startNs: 10n }))).toBeUndefined(); + }); +}); + +describe("rollupByPhase", () => { + test("aggregates total, count, and percentiles per phase", () => { + const spans = nestedTurnFixture(); + const phases = rollupByPhase(spans); + const byName = Object.fromEntries(phases.map((p) => [p.name, p])); + + expect(byName.turn).toMatchObject({ count: 1, openCount: 0, totalNs: 2000 }); + expect(byName.inference).toMatchObject({ count: 1, totalNs: 1000 }); + expect(byName["inference.ttft"]).toMatchObject({ count: 1, totalNs: 200 }); + expect(byName["inference.stream"]).toMatchObject({ count: 1, totalNs: 800 }); + expect(byName.tool).toMatchObject({ count: 2, totalNs: 400, p50Ns: 100, p95Ns: 300 }); + }); + + test("open spans count but do not contribute to duration stats", () => { + const spans: PerfSpan[] = [ + span({ id: "a", name: "tool", startNs: 0n, endNs: 100n }), + span({ id: "b", name: "tool", startNs: 0n }), // open + ]; + const tool = rollupByPhase(spans).find((p) => p.name === "tool")!; + expect(tool.count).toBe(2); + expect(tool.openCount).toBe(1); + expect(tool.totalNs).toBe(100); + expect(tool.p50Ns).toBe(100); + }); + + test("empty snapshot yields empty phase list", () => { + expect(rollupByPhase([])).toEqual([]); + }); +}); + +describe("rollupByTurn", () => { + test("attributes nested inference, ttft, stream, and tools under a turn", () => { + const turns = rollupByTurn(nestedTurnFixture()); + expect(turns).toHaveLength(1); + expect(turns[0]).toEqual({ + turnId: "t1", + turnNs: 2000, + open: false, + inferenceNs: 1000, + toolNs: 400, + ttftNs: 200, + streamNs: 800, + toolCount: 2, + }); + }); + + test("multiple turns stay independent", () => { + const spans: PerfSpan[] = [ + span({ id: "t1", name: "turn", startNs: 0n, endNs: 1000n }), + span({ + id: "i1", + name: "inference", + parentId: "t1", + startNs: 0n, + endNs: 500n, + }), + span({ + id: "ttft1", + name: "inference.ttft", + parentId: "i1", + startNs: 0n, + endNs: 100n, + }), + span({ + id: "stream1", + name: "inference.stream", + parentId: "i1", + startNs: 100n, + endNs: 500n, + }), + span({ id: "t2", name: "turn", startNs: 2000n, endNs: 3500n }), + span({ + id: "i2", + name: "inference", + parentId: "t2", + startNs: 2000n, + endNs: 3000n, + }), + span({ + id: "ttft2", + name: "inference.ttft", + parentId: "i2", + startNs: 2000n, + endNs: 2200n, + }), + span({ + id: "stream2", + name: "inference.stream", + parentId: "i2", + startNs: 2200n, + endNs: 3000n, + }), + span({ + id: "k2", + name: "tool", + parentId: "t2", + startNs: 3100n, + endNs: 3400n, + }), + ]; + + const turns = rollupByTurn(spans); + expect(turns).toHaveLength(2); + expect(turns[0]!.turnId).toBe("t1"); + expect(turns[0]!.inferenceNs).toBe(500); + expect(turns[0]!.ttftNs).toBe(100); + expect(turns[0]!.streamNs).toBe(400); + expect(turns[0]!.toolCount).toBe(0); + expect(turns[1]!.turnId).toBe("t2"); + expect(turns[1]!.inferenceNs).toBe(1000); + expect(turns[1]!.ttftNs).toBe(200); + expect(turns[1]!.streamNs).toBe(800); + expect(turns[1]!.toolNs).toBe(300); + expect(turns[1]!.toolCount).toBe(1); + }); + + test("open turn is flagged and turnNs is 0", () => { + const turns = rollupByTurn([ + span({ id: "t1", name: "turn", startNs: 0n }), + span({ + id: "i1", + name: "inference", + parentId: "t1", + startNs: 0n, + endNs: 50n, + }), + ]); + expect(turns[0]).toMatchObject({ open: true, turnNs: 0, inferenceNs: 50 }); + }); +}); + +describe("sessionTotals", () => { + test("sums across turns and reports TTFT vs stream share", () => { + const spans: PerfSpan[] = [ + span({ id: "t1", name: "turn", startNs: 0n, endNs: 1000n }), + span({ + id: "i1", + name: "inference", + parentId: "t1", + startNs: 0n, + endNs: 500n, + }), + span({ + id: "ttft1", + name: "inference.ttft", + parentId: "i1", + startNs: 0n, + endNs: 100n, + }), + span({ + id: "stream1", + name: "inference.stream", + parentId: "i1", + startNs: 100n, + endNs: 500n, + }), + span({ id: "t2", name: "turn", startNs: 2000n, endNs: 3000n }), + span({ + id: "i2", + name: "inference", + parentId: "t2", + startNs: 2000n, + endNs: 2800n, + }), + span({ + id: "ttft2", + name: "inference.ttft", + parentId: "i2", + startNs: 2000n, + endNs: 2300n, + }), + span({ + id: "stream2", + name: "inference.stream", + parentId: "i2", + startNs: 2300n, + endNs: 2800n, + }), + span({ + id: "k1", + name: "tool", + parentId: "t2", + startNs: 2800n, + endNs: 2900n, + }), + ]; + + const totals = sessionTotals(spans); + expect(totals.turnCount).toBe(2); + expect(totals.completedTurnCount).toBe(2); + expect(totals.totalTurnNs).toBe(2000); + expect(totals.totalInferenceNs).toBe(1300); + expect(totals.totalTtftNs).toBe(400); // 100 + 300 + expect(totals.totalStreamNs).toBe(900); // 400 + 500 + expect(totals.totalToolNs).toBe(100); + expect(totals.totalToolCount).toBe(1); + expect(totals.ttftShare).toBeCloseTo(400 / 1300, 10); + expect(totals.streamShare).toBeCloseTo(900 / 1300, 10); + expect(totals.ttftShare + totals.streamShare).toBeCloseTo(1, 10); + }); + + test("zero TTFT and stream yields zero shares", () => { + const totals = sessionTotals([ + span({ id: "t1", name: "turn", startNs: 0n, endNs: 10n }), + ]); + expect(totals.ttftShare).toBe(0); + expect(totals.streamShare).toBe(0); + }); + + test("empty snapshot yields zeros", () => { + expect(sessionTotals([])).toEqual({ + turnCount: 0, + completedTurnCount: 0, + totalTurnNs: 0, + totalInferenceNs: 0, + totalToolNs: 0, + totalTtftNs: 0, + totalStreamNs: 0, + totalToolCount: 0, + ttftShare: 0, + streamShare: 0, + }); + }); +}); + +describe("dumpSpans", () => { + test("writes compact JSON beside the given dir and returns the path", async () => { + const dir = await mkdtemp(join(tmpdir(), "perftrace-dump-")); + try { + const spans = nestedTurnFixture(); + const path = await dumpSpans(spans, { dir, sessionId: "sess-abc" }); + expect(path).toBe(join(dir, "perftrace-sess-abc.json")); + + const raw = await readFile(path, "utf8"); + const dump = JSON.parse(raw); + expect(dump.version).toBe(1); + expect(dump.sessionId).toBe("sess-abc"); + expect(dump.spanCount).toBe(spans.length); + expect(dump.rollup.byTurn).toHaveLength(1); + expect(dump.rollup.session.totalToolCount).toBe(2); + expect(dump.spans).toHaveLength(spans.length); + // Compact: single trailing newline, no pretty indent. + expect(raw.endsWith("\n")).toBe(true); + expect(raw.trimStart().startsWith("{")).toBe(true); + expect(raw.includes("\n ")).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("rejects path-like session ids", async () => { + await expect( + dumpSpans([], { dir: "/tmp", sessionId: "../etc/passwd" }), + ).rejects.toThrow(/sessionId/); + }); + + test("serializes open spans without endNs", () => { + const s = serializeSpan(span({ id: "x", name: "session", startNs: 42n })); + expect(s.open).toBe(true); + expect(s.endNs).toBeUndefined(); + expect(s.startNs).toBe("42"); + }); +}); + +describe("privacy fixture", () => { + test("dump contains only allowlisted span fields and tag keys", async () => { + // Live ring path: tags go through sanitizeTags on start/end. + const turnId = start("turn", { + tags: { + turn_id: "t-1", + // forbidden — must never appear + prompt: "system: you are a helpful assistant", + path: "/Users/me/secret/repo/src/main.ts", + error: "ENOENT: no such file", + }, + }); + const infId = start("inference", { + parentId: turnId, + tags: { + provider_id: "openai", + model_id: "gpt-5.4", + completion: "sure, here is the code", + }, + }); + end(infId, { input_tokens: 10, output_tokens: 5, message: "drop me" }); + const toolId = start("tool", { + parentId: turnId, + tags: { tool_id: "shell", tool_args: "rm -rf /" }, + }); + end(toolId, { count: 1 }); + end(turnId); + + // Also inject a hand-built span that pretends to carry free text, to prove + // serializeSpan re-sanitizes even when the in-memory shape is dirty. + const dirty = { + id: "dirty", + name: "adapter.transport" as const, + startNs: 1n, + endNs: 2n, + tags: { + transport: "http_sse" as const, + prompt: "should never dump", + stack: "Error\n at foo", + }, + } as unknown as PerfSpan; + + const spans = [...snapshot(), dirty]; + const dump = buildDump(spans, "privacy-fixture", "2026-04-08T00:00:00.000Z"); + + // Top-level keys are a fixed allowlist. + expect(Object.keys(dump).sort()).toEqual( + ["openCount", "rollup", "sessionId", "spanCount", "spans", "version", "writtenAt"].sort(), + ); + + // No free-text substrings anywhere in the serialized dump. + const json = JSON.stringify(dump); + for (const banned of [ + "helpful assistant", + "/Users/me", + "ENOENT", + "sure, here is the code", + "rm -rf", + "should never dump", + "at foo", + "drop me", + ]) { + expect(json.includes(banned)).toBe(false); + } + + // Every tag key on every span is allowlisted. + expect(collectNonAllowlistedTagKeys(dump)).toEqual([]); + for (const s of dump.spans) { + for (const key of Object.keys(s)) { + expect(DUMP_SPAN_KEY_SET.has(key)).toBe(true); + } + if (s.tags !== undefined) { + for (const key of Object.keys(s.tags)) { + expect(ALLOWED_TAG_KEY_SET.has(key)).toBe(true); + } + } + } + + // Allowlisted tags that were supplied are retained. + const tool = dump.spans.find((s) => s.name === "tool"); + expect(tool?.tags).toEqual({ tool_id: "shell", count: 1 }); + const transport = dump.spans.find((s) => s.id === "dirty"); + expect(transport?.tags).toEqual({ transport: "http_sse" }); + }); +}); + +describe("edge: ring eviction and open spans in snapshot", () => { + test("rollup tolerates more than RING_CAPACITY completed spans", () => { + for (let i = 0; i < RING_CAPACITY + 25; i += 1) { + mark("tool", { tags: { count: i } }); + } + const spans = snapshot(); + expect(spans).toHaveLength(RING_CAPACITY); + + const phases = rollupByPhase(spans); + const tool = phases.find((p) => p.name === "tool")!; + expect(tool.count).toBe(RING_CAPACITY); + // Durations of mark() are zero (startNs === endNs). + expect(tool.totalNs).toBe(0); + }); + + test("sessionTotals falls back to flat phase sums when turn roots are gone", () => { + // Orphan inference + ttft after a hypothetical ring eviction of the turn. + const spans: PerfSpan[] = [ + span({ + id: "i1", + name: "inference", + parentId: "missing-turn", + startNs: 0n, + endNs: 500n, + }), + span({ + id: "ttft1", + name: "inference.ttft", + parentId: "i1", + startNs: 0n, + endNs: 100n, + }), + span({ + id: "stream1", + name: "inference.stream", + parentId: "i1", + startNs: 100n, + endNs: 500n, + }), + ]; + const totals = sessionTotals(spans); + expect(totals.turnCount).toBe(0); + expect(totals.totalInferenceNs).toBe(500); + expect(totals.totalTtftNs).toBe(100); + expect(totals.totalStreamNs).toBe(400); + expect(totals.ttftShare).toBeCloseTo(0.2, 10); + }); +}); diff --git a/src/perf/rollup.ts b/src/perf/rollup.ts new file mode 100644 index 000000000..929cab8d2 --- /dev/null +++ b/src/perf/rollup.ts @@ -0,0 +1,274 @@ +/** + * Pure rollup helpers over PerfSpan snapshots. + * + * No I/O, no module state. Inputs are bigint nanoseconds; outputs use plain + * numbers so they serialize with JSON without custom revivers. Types are + * exported for reuse by a future OTEL sink. + */ + +import type { PerfSpan, SpanName } from "./index.js"; + +/** Per-phase aggregate: total wall under that name, count, and percentiles. */ +export type PhaseSummary = { + name: SpanName; + count: number; + /** Spans still open (no endNs) — excluded from duration stats. */ + openCount: number; + totalNs: number; + p50Ns: number; + p95Ns: number; +}; + +/** One turn and the nested inference / tool / TTFT / stream cost under it. */ +export type TurnSummary = { + turnId: string; + /** Wall time of the turn span itself; 0 when still open. */ + turnNs: number; + open: boolean; + inferenceNs: number; + toolNs: number; + ttftNs: number; + streamNs: number; + toolCount: number; +}; + +/** Session-wide sums and TTFT vs stream split. */ +export type SessionTotals = { + turnCount: number; + completedTurnCount: number; + totalTurnNs: number; + totalInferenceNs: number; + totalToolNs: number; + totalTtftNs: number; + totalStreamNs: number; + totalToolCount: number; + /** + * Share of (ttft + stream) spent in TTFT. 0 when both sides are zero. + * Values are in [0, 1]. + */ + ttftShare: number; + /** Share of (ttft + stream) spent streaming after first token. */ + streamShare: number; +}; + +/** Completed duration in ns, or undefined when the span is still open. */ +export function spanDurationNs(span: PerfSpan): number | undefined { + if (span.endNs === undefined) return undefined; + const d = span.endNs - span.startNs; + if (d <= 0n) return 0; + // Process-lifetime hrtime deltas stay well inside Number.MAX_SAFE_INTEGER. + return Number(d); +} + +function percentileNearestRank(sortedAsc: readonly number[], p: number): number { + if (sortedAsc.length === 0) return 0; + // Nearest-rank: ceil(p * n), 1-indexed → 0-indexed clamp. + const rank = Math.ceil(p * sortedAsc.length) - 1; + const idx = Math.min(sortedAsc.length - 1, Math.max(0, rank)); + return sortedAsc[idx]!; +} + +/** + * Aggregate every span by phase name. Open spans contribute to count/openCount + * but not to totalNs or percentiles. + */ +export function rollupByPhase(spans: readonly PerfSpan[]): PhaseSummary[] { + const byName = new Map< + SpanName, + { durations: number[]; openCount: number; count: number } + >(); + + for (const span of spans) { + let bucket = byName.get(span.name); + if (bucket === undefined) { + bucket = { durations: [], openCount: 0, count: 0 }; + byName.set(span.name, bucket); + } + bucket.count += 1; + const dur = spanDurationNs(span); + if (dur === undefined) { + bucket.openCount += 1; + } else { + bucket.durations.push(dur); + } + } + + const out: PhaseSummary[] = []; + for (const [name, bucket] of byName) { + const sorted = bucket.durations.slice().sort((a, b) => a - b); + let totalNs = 0; + for (const d of sorted) totalNs += d; + out.push({ + name, + count: bucket.count, + openCount: bucket.openCount, + totalNs, + p50Ns: percentileNearestRank(sorted, 0.5), + p95Ns: percentileNearestRank(sorted, 0.95), + }); + } + + // Stable order: SPAN_NAMES order first, then any remaining by name. + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +/** + * Build parent → children index. Orphans (parent missing after ring eviction) + * still appear as roots for by-phase; by-turn only lists actual turn spans. + */ +function childrenOf(spans: readonly PerfSpan[]): Map { + const byParent = new Map(); + for (const span of spans) { + if (span.parentId === undefined) continue; + const list = byParent.get(span.parentId); + if (list === undefined) { + byParent.set(span.parentId, [span]); + } else { + list.push(span); + } + } + return byParent; +} + +/** Depth-first walk of the subtree rooted at `rootId` (excluding the root). */ +function walkDescendants( + rootId: string, + byParent: Map, + visit: (span: PerfSpan) => void, +): void { + const stack = byParent.get(rootId); + if (stack === undefined) return; + // Copy so callers can mutate freely; walk iteratively to avoid deep recursion. + const work: PerfSpan[] = stack.slice(); + while (work.length > 0) { + const span = work.pop()!; + visit(span); + const kids = byParent.get(span.id); + if (kids !== undefined) { + for (const k of kids) work.push(k); + } + } +} + +/** + * Per-turn rollup. Children are attributed via parentId links + * (turn → inference → {ttft, stream}; turn → tool). + * Turns are ordered by startNs ascending. + */ +export function rollupByTurn(spans: readonly PerfSpan[]): TurnSummary[] { + const byParent = childrenOf(spans); + const turns = spans + .filter((s) => s.name === "turn") + .slice() + .sort((a, b) => (a.startNs < b.startNs ? -1 : a.startNs > b.startNs ? 1 : 0)); + + return turns.map((turn) => { + let inferenceNs = 0; + let toolNs = 0; + let ttftNs = 0; + let streamNs = 0; + let toolCount = 0; + + walkDescendants(turn.id, byParent, (child) => { + const dur = spanDurationNs(child) ?? 0; + switch (child.name) { + case "inference": + inferenceNs += dur; + break; + case "tool": + toolNs += dur; + toolCount += 1; + break; + case "inference.ttft": + ttftNs += dur; + break; + case "inference.stream": + streamNs += dur; + break; + default: + break; + } + }); + + const turnDur = spanDurationNs(turn); + return { + turnId: turn.id, + turnNs: turnDur ?? 0, + open: turnDur === undefined, + inferenceNs, + toolNs, + ttftNs, + streamNs, + toolCount, + }; + }); +} + +/** + * Session totals summed across turns, plus TTFT vs stream ratio over the + * whole snapshot (not only under turns — orphans still count toward phase sums). + */ +export function sessionTotals(spans: readonly PerfSpan[]): SessionTotals { + const turns = rollupByTurn(spans); + + let totalTurnNs = 0; + let totalInferenceNs = 0; + let totalToolNs = 0; + let totalTtftNs = 0; + let totalStreamNs = 0; + let totalToolCount = 0; + let completedTurnCount = 0; + + for (const t of turns) { + totalTurnNs += t.turnNs; + totalInferenceNs += t.inferenceNs; + totalToolNs += t.toolNs; + totalTtftNs += t.ttftNs; + totalStreamNs += t.streamNs; + totalToolCount += t.toolCount; + if (!t.open) completedTurnCount += 1; + } + + // Prefer turn-scoped sums; if no turns, fall back to flat phase totals so a + // partial snapshot (evicted roots) still reports something useful. + if (turns.length === 0) { + for (const span of spans) { + const dur = spanDurationNs(span) ?? 0; + switch (span.name) { + case "inference": + totalInferenceNs += dur; + break; + case "tool": + totalToolNs += dur; + totalToolCount += 1; + break; + case "inference.ttft": + totalTtftNs += dur; + break; + case "inference.stream": + totalStreamNs += dur; + break; + default: + break; + } + } + } + + const split = totalTtftNs + totalStreamNs; + const ttftShare = split === 0 ? 0 : totalTtftNs / split; + const streamShare = split === 0 ? 0 : totalStreamNs / split; + + return { + turnCount: turns.length, + completedTurnCount, + totalTurnNs, + totalInferenceNs, + totalToolNs, + totalTtftNs, + totalStreamNs, + totalToolCount, + ttftShare, + streamShare, + }; +}