Skip to content

Commit 8f50e30

Browse files
Merge pull request #307 from corbitsdev/stack/w3a-cl-5169-dump-rollup
Local metrics dump and rollup helpers (CL-5169)
2 parents d15010a + b0471c9 commit 8f50e30

4 files changed

Lines changed: 959 additions & 1 deletion

File tree

docs/PERFTRACE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ PostHog usage events.
1010
- In-process ring buffer of phase spans (turn, inference, tools, …)
1111
- Privacy-strict tags: enums, ids, and numbers only — no prompts, paths, tool
1212
args, free-text errors, or credentials
13-
- Future session dumps (CL-5169) use the same allowlist and must never include
13+
- Offline dumps: `dumpSpans` + `rollupByPhase` / `rollupByTurn` / `sessionTotals`
14+
(`src/perf/dump.ts`, `src/perf/rollup.ts`) — same tag allowlist; never include
1415
OTEL auth headers
1516

1617
Local measurement does not require any settings or env vars.

src/perf/dump.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Privacy-strict local dump of a PerfSpan snapshot.
3+
*
4+
* Writes compact JSON beside session artifacts. Re-sanitizes tags and strips
5+
* any non-allowlisted shape so the file is safe to share offline.
6+
* No network.
7+
*/
8+
9+
import { mkdir, writeFile } from "node:fs/promises";
10+
import { join } from "node:path";
11+
import {
12+
ALLOWED_TAG_KEYS,
13+
type PerfSpan,
14+
type PerfTags,
15+
type SpanName,
16+
sanitizeTags,
17+
} from "./index.js";
18+
import {
19+
rollupByPhase,
20+
rollupByTurn,
21+
sessionTotals,
22+
type PhaseSummary,
23+
type SessionTotals,
24+
type TurnSummary,
25+
} from "./rollup.js";
26+
27+
/** Dump schema version — bump when the on-disk shape changes incompatibly. */
28+
export const DUMP_VERSION = 1 as const;
29+
30+
/** Allowlisted keys that may appear on a serialized span object. */
31+
export const DUMP_SPAN_KEYS = [
32+
"id",
33+
"name",
34+
"parentId",
35+
"startNs",
36+
"endNs",
37+
"open",
38+
"tags",
39+
] as const;
40+
41+
export type DumpSpan = {
42+
id: string;
43+
name: SpanName;
44+
parentId?: string;
45+
/** Absolute monotonic ns as decimal string (preserves bigint precision). */
46+
startNs: string;
47+
endNs?: string;
48+
/** Present and true when the span was still open at dump time. */
49+
open?: true;
50+
tags?: PerfTags;
51+
};
52+
53+
export type PerfDump = {
54+
version: typeof DUMP_VERSION;
55+
sessionId: string;
56+
/** ISO-8601 wall clock when the dump was written (not span time). */
57+
writtenAt: string;
58+
spanCount: number;
59+
openCount: number;
60+
rollup: {
61+
byPhase: PhaseSummary[];
62+
byTurn: TurnSummary[];
63+
session: SessionTotals;
64+
};
65+
spans: DumpSpan[];
66+
};
67+
68+
export type DumpOptions = {
69+
/** Directory that already holds (or will hold) session artifacts. */
70+
dir: string;
71+
/** Opaque session id — used only in the filename and dump header. */
72+
sessionId: string;
73+
};
74+
75+
// Session ids in the product are opaque short strings; reject path traversal.
76+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
77+
78+
const ALLOWED_TAG_KEY_SET: ReadonlySet<string> = new Set(ALLOWED_TAG_KEYS);
79+
80+
function assertSafeSessionId(sessionId: string): void {
81+
if (!SAFE_SESSION_ID_RE.test(sessionId)) {
82+
throw new Error(
83+
`dumpSpans: sessionId must be a short opaque id (got ${JSON.stringify(sessionId)})`,
84+
);
85+
}
86+
}
87+
88+
/**
89+
* Project a live PerfSpan onto the dump allowlist.
90+
* Bigints become decimal strings; tags are re-sanitized.
91+
*/
92+
export function serializeSpan(span: PerfSpan): DumpSpan {
93+
const out: DumpSpan = {
94+
id: span.id,
95+
name: span.name,
96+
startNs: span.startNs.toString(),
97+
};
98+
if (span.parentId !== undefined) {
99+
out.parentId = span.parentId;
100+
}
101+
if (span.endNs === undefined) {
102+
out.open = true;
103+
} else {
104+
out.endNs = span.endNs.toString();
105+
}
106+
// Defense in depth: re-run the privacy fence even if the in-memory span
107+
// somehow carried extra keys (e.g. test fixtures or future sinks).
108+
const tags = sanitizeTags(span.tags as Record<string, unknown> | undefined);
109+
if (tags !== undefined) {
110+
out.tags = tags;
111+
}
112+
return out;
113+
}
114+
115+
/** Build the dump document without touching the filesystem. */
116+
export function buildDump(spans: readonly PerfSpan[], sessionId: string, writtenAt: string): PerfDump {
117+
assertSafeSessionId(sessionId);
118+
const serialized = spans.map(serializeSpan);
119+
let openCount = 0;
120+
for (const s of serialized) {
121+
if (s.open === true) openCount += 1;
122+
}
123+
return {
124+
version: DUMP_VERSION,
125+
sessionId,
126+
writtenAt,
127+
spanCount: serialized.length,
128+
openCount,
129+
rollup: {
130+
byPhase: rollupByPhase(spans),
131+
byTurn: rollupByTurn(spans),
132+
session: sessionTotals(spans),
133+
},
134+
spans: serialized,
135+
};
136+
}
137+
138+
/**
139+
* Write `perftrace-{sessionId}.json` under `opts.dir`.
140+
* Returns the absolute-or-relative path written.
141+
*/
142+
export async function dumpSpans(
143+
spans: readonly PerfSpan[],
144+
opts: DumpOptions,
145+
): Promise<string> {
146+
assertSafeSessionId(opts.sessionId);
147+
const dump = buildDump(spans, opts.sessionId, new Date().toISOString());
148+
const filePath = join(opts.dir, `perftrace-${opts.sessionId}.json`);
149+
await mkdir(opts.dir, { recursive: true });
150+
// Compact single-line JSON keeps diffs and `jq` usage simple.
151+
await writeFile(filePath, `${JSON.stringify(dump)}\n`, "utf8");
152+
return filePath;
153+
}
154+
155+
/**
156+
* Walk a parsed dump and return every tag key that is not allowlisted.
157+
* Used by the privacy fixture test; also handy for operator scripts.
158+
*/
159+
export function collectNonAllowlistedTagKeys(dump: PerfDump): string[] {
160+
const bad: string[] = [];
161+
for (const span of dump.spans) {
162+
if (span.tags === undefined) continue;
163+
for (const key of Object.keys(span.tags)) {
164+
if (!ALLOWED_TAG_KEY_SET.has(key)) bad.push(key);
165+
}
166+
}
167+
return bad;
168+
}

0 commit comments

Comments
 (0)