Skip to content

Commit a24eb9e

Browse files
Merge pull request #311 from corbitsdev/stack/w5-cl-5167-attribution
Attribution report on a pain session (CL-5167)
2 parents 74df870 + a892cf3 commit a24eb9e

6 files changed

Lines changed: 1418 additions & 2 deletions

File tree

docs/PERFTRACE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ PostHog usage events.
1313
- Offline dumps: `dumpSpans` + `rollupByPhase` / `rollupByTurn` / `sessionTotals`
1414
(`src/perf/dump.ts`, `src/perf/rollup.ts`) — same tag allowlist; never include
1515
OTEL auth headers
16+
- Attribution report: `bun scripts/perf-report.ts <dump.json>` — see
17+
[`perftrace-attribution-guide.md`](./perftrace-attribution-guide.md)
1618

1719
Local measurement does not require any settings or env vars.
1820

21+
1922
## OTEL export (opt-in)
2023

2124
Export is **off** until an OTLP endpoint is configured. When enabled, traces go
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# PerfTrace attribution guide
2+
3+
How to capture a slow session, dump local spans, and run the offline attribution
4+
report. No OTEL collector, no PostHog, no network.
5+
6+
See also: [`PERFTRACE.md`](./PERFTRACE.md) for the span model, dump schema, and
7+
`jq` recipes.
8+
9+
## Why this exists
10+
11+
When a session feels slow, the first question is **where the wall time went**:
12+
13+
| Category | Meaning |
14+
|---|---|
15+
| `inference` | Model call wall (`inference` spans). Nested `inference.ttft` vs `inference.stream` show wait-for-first-token vs rest of stream. |
16+
| `tools` | Tool invocations under the turn. |
17+
| `permission.wait` | Ask-gate / approval idle time (when instrumented). |
18+
| `subagent` | Child agent lifetimes (fanout cost). |
19+
| `other` | Turn wall not covered by the above — scheduling, TUI, un-instrumented work, gaps between phases. |
20+
21+
Shares are **exclusive** over turn wall. For **completed** turns, wall is
22+
`end − start`. For **open** (still-running) turns — including mid-stall dumps —
23+
wall is estimated as `max(completed-descendant endNs) − turn.startNs` so shares
24+
stay meaningful, and the report lists **open phase names** (e.g.
25+
`inference.stream`, `turn`) so completed-only shares are not read as a full
26+
stall diagnosis.
27+
28+
Nested exclusive children under an exclusive parent (e.g. `tool` /
29+
`inference` under `subagent`) count only toward the parent exclusive bucket —
30+
they are not double-counted. Nested TTFT/stream and `adapter.transport` are
31+
diagnostic splits (they are not added on top of `inference` in the exclusive
32+
table). TTFT/stream shares use **(ttft + stream)** as the denominator, not
33+
inference wall.
34+
35+
36+
## Capture a real slow session
37+
38+
1. Prefer a repro that exercises the pain: high reasoning, several tools, and
39+
(if relevant) subagents or permission prompts.
40+
2. Run Corbits Code normally. PerfTrace is always-on in-process; there is no
41+
settings toggle.
42+
3. When the session stalls or finishes slowly, dump the ring next to session
43+
artifacts.
44+
45+
```ts
46+
import { snapshot } from "../src/perf/index.js";
47+
import { dumpSpans } from "../src/perf/dump.js";
48+
49+
const path = await dumpSpans(snapshot(), {
50+
dir: ".agent-state/<sessionId>",
51+
sessionId: "<sessionId>",
52+
});
53+
// → .agent-state/<sessionId>/perftrace-<sessionId>.json
54+
```
55+
56+
The dump is privacy-strict (allowlisted tags only). Safe to keep offline or
57+
share with teammates without prompts/paths.
58+
59+
## Run the attribution report
60+
61+
From a local dump file alone:
62+
63+
```bash
64+
bun scripts/perf-report.ts .agent-state/<sessionId>/perftrace-<sessionId>.json
65+
```
66+
67+
Machine-readable JSON:
68+
69+
```bash
70+
bun scripts/perf-report.ts --json .agent-state/<sessionId>/perftrace-<sessionId>.json
71+
```
72+
73+
Golden multi-tool demo (no dump file needed — uses
74+
`src/perf/fixtures/multi-tool-turn.ts`):
75+
76+
```bash
77+
bun scripts/perf-report.ts --fixture
78+
```
79+
80+
Example fixture output (fixture ns values are tiny — formatter prints sub-ms):
81+
82+
```
83+
PerfTrace attribution report
84+
───────────────────────────
85+
Session wall: 0.005ms turns=1 (completed=1)
86+
87+
Exclusive phase shares (of session wall):
88+
inference 40.0% 0.002ms n=1
89+
tools 24.0% 0.001ms n=2
90+
permission.wait 8.0% 0.000ms
91+
subagent 0.0% 0ms
92+
other 28.0% 0.001ms
93+
...
94+
```
95+
96+
97+
## How to read the report
98+
99+
1. **Session exclusive shares** — which bucket ate the turn wall. A large
100+
`inference` share with high `ttft` points at model queueing / cold start. A
101+
large `tools` share with high `n=` points at tool work. A large
102+
`permission.wait` share is human/ask-gate idle, not model or tool code.
103+
2. **Open / incomplete** — if the report says `Open (incomplete)` and lists
104+
still-running phases, exclusive shares only cover completed descendants.
105+
Treat open phase names as the hang candidates; do not conclude from the
106+
exclusive table alone.
107+
3. **`other` large** — either real un-instrumented cost (TUI, scheduling) or
108+
gaps between instrumented phases. If `other` dominates a pain session, add
109+
spans before optimizing transport.
110+
4. **TTFT vs stream** — of `ttft + stream` only (not inference wall). High TTFT
111+
share → time-to-first-token problem. High stream share → long generation or
112+
slow token delivery.
113+
5. **Transport signal**`adapter.transport / inference`. When transport is a
114+
large fraction of inference wall, prioritize transport work (WebSocket /
115+
incremental input). When it is small, transport is not the bottleneck.
116+
6. **Per-turn rows** — find the outlier turn when the session average looks fine
117+
but one turn felt stuck. Open turns print `open phases:` explicitly.
118+
7. **Subagent count + share** — fanout cost. High subagent share means child
119+
agents as a whole (nested tools/inference under the subagent are inside that
120+
bucket, not double-counted as parent tools/inference).
121+
122+
### What “large transport share” looks like
123+
124+
| transportShareOfInference | Reading |
125+
|---|---|
126+
| ≈ 0 or missing | Adapter did not emit `adapter.transport`, or transport was negligible. Do not prioritize WebSocket/incremental input on this evidence alone. |
127+
| Low (e.g. &lt; 10–15%) | Most inference wall is model/server time, not client transport. Prefer model/TTFT or tool work. |
128+
| High (e.g. &gt; 25–30% of inference, sustained across turns) | Client transport is a meaningful slice of inference wall — candidate for WebSocket / incremental input priority. |
129+
130+
Always pair with absolute ms: a 40% share of a 50ms inference is noise; 40% of a
131+
8s inference is a product decision.
132+
133+
## Decision note template (transport prioritization)
134+
135+
Copy into a Linear issue or PR when a pain dump suggests transport investment.
136+
137+
```markdown
138+
## Decision: WebSocket / incremental input priority?
139+
140+
**Session / dump:** <path to perftrace-*.json>
141+
**Report command:** `bun scripts/perf-report.ts <path>`
142+
143+
### Evidence
144+
- Session wall: <ms>
145+
- Exclusive shares: inference <%> · tools <%> · permission.wait <%> · subagent <%> · other <%>
146+
- TTFT share of (ttft+stream): <%>
147+
- Stream share of (ttft+stream): <%>
148+
- `adapter.transport` ns: <ms> · share of inference: <%>
149+
- Turns examined: <n>; outlier turn id: <id>
150+
151+
### Reading
152+
- [ ] Transport share is **high** and absolute transport ms is user-visible
153+
→ prioritize WebSocket / incremental input (or adapter transport work).
154+
- [ ] Transport share is **low / missing**; inference TTFT or tools dominate
155+
→ do **not** prioritize transport; focus on <TTFT | tools | permission | other>.
156+
- [ ] `other` or missing instrumentation dominates
157+
→ instrument first; decide after a second dump.
158+
159+
### Decision
160+
- Priority: <raise | hold | drop> transport work this cycle
161+
- Owner: <name>
162+
- Follow-up: <issue link or none>
163+
```
164+
165+
## API (programmatic)
166+
167+
```ts
168+
import {
169+
attributionFromSpans,
170+
attributionFromDump,
171+
formatAttributionReport,
172+
} from "../src/perf/attribution-report.js";
173+
import { snapshot } from "../src/perf/index.js";
174+
175+
const report = attributionFromSpans(snapshot());
176+
console.log(formatAttributionReport(report));
177+
// or: attributionFromDump(JSON.parse(await readFile(path, "utf8")))
178+
```
179+
180+
Pure functions — safe in tests and evals. The multi-tool golden fixture locks
181+
expected ns values in `src/perf/fixtures/multi-tool-turn.ts` and
182+
`src/perf/attribution-report.test.ts`.
183+
184+
## Related
185+
186+
- `src/perf/rollup.ts` — phase / turn / session totals
187+
- `src/perf/dump.ts``dumpSpans` / `buildDump`
188+
- `src/perf/attribution-report.ts` — exclusive shares + formatter
189+
- `scripts/perf-report.ts` — CLI entrypoint

scripts/perf-report.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Offline PerfTrace attribution report.
4+
*
5+
* Reads a dump JSON written by dumpSpans() (or a bare spans array) and prints
6+
* exclusive phase shares: inference / tools / permission.wait / subagent / other.
7+
* No network, no OTEL, no PostHog.
8+
*
9+
* Usage:
10+
* bun scripts/perf-report.ts <path-to-perftrace-*.json>
11+
* bun scripts/perf-report.ts --json <path>
12+
* bun scripts/perf-report.ts --fixture # golden multi-tool demo
13+
*/
14+
15+
import { readFile } from "node:fs/promises";
16+
import { resolve } from "node:path";
17+
import {
18+
attributionFromDump,
19+
attributionFromSpans,
20+
formatAttributionReport,
21+
type AttributionReport,
22+
} from "../src/perf/attribution-report.js";
23+
import { multiToolTurnFixture } from "../src/perf/fixtures/multi-tool-turn.js";
24+
25+
function printUsage(): void {
26+
console.error(`Usage:
27+
bun scripts/perf-report.ts <path-to-perftrace-*.json>
28+
bun scripts/perf-report.ts --json <path> # machine-readable AttributionReport
29+
bun scripts/perf-report.ts --fixture # demo on multi-tool golden fixture
30+
`);
31+
}
32+
33+
function emit(report: AttributionReport, asJson: boolean): void {
34+
if (asJson) {
35+
console.log(JSON.stringify(report, null, 2));
36+
} else {
37+
process.stdout.write(formatAttributionReport(report));
38+
}
39+
}
40+
41+
async function main(argv: string[]): Promise<number> {
42+
const args = argv.slice(2);
43+
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
44+
printUsage();
45+
return args.length === 0 ? 1 : 0;
46+
}
47+
48+
const asJson = args.includes("--json");
49+
const useFixture = args.includes("--fixture");
50+
const pathArg = args.find((a) => !a.startsWith("-"));
51+
52+
if (useFixture) {
53+
const report = attributionFromSpans(multiToolTurnFixture());
54+
emit(report, asJson);
55+
return 0;
56+
}
57+
58+
if (pathArg === undefined) {
59+
printUsage();
60+
return 1;
61+
}
62+
63+
const filePath = resolve(pathArg);
64+
let rawText: string;
65+
try {
66+
rawText = await readFile(filePath, "utf8");
67+
} catch (err) {
68+
const msg = err instanceof Error ? err.message : String(err);
69+
console.error(`perf-report: failed to read ${filePath}: ${msg}`);
70+
return 1;
71+
}
72+
73+
let parsed: unknown;
74+
try {
75+
parsed = JSON.parse(rawText);
76+
} catch (err) {
77+
const msg = err instanceof Error ? err.message : String(err);
78+
console.error(`perf-report: invalid JSON in ${filePath}: ${msg}`);
79+
return 1;
80+
}
81+
82+
try {
83+
const report = attributionFromDump(parsed);
84+
emit(report, asJson);
85+
return 0;
86+
} catch (err) {
87+
const msg = err instanceof Error ? err.message : String(err);
88+
console.error(`perf-report: ${msg}`);
89+
return 1;
90+
}
91+
}
92+
93+
const code = await main(process.argv);
94+
process.exit(code);

0 commit comments

Comments
 (0)