Skip to content

Commit 73158b2

Browse files
committed
Add local PerfTrace core with ring buffer and tag allowlist
Always-on in-process span API (start/end/mark) using monotonic hrtime, a fixed 4096-span ring, and privacy-sanitized tags. No network or PostHog; snapshot/clear for tests.
1 parent 7edbeaa commit 73158b2

3 files changed

Lines changed: 520 additions & 0 deletions

File tree

src/perf/index.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import {
3+
RING_CAPACITY,
4+
clear,
5+
end,
6+
mark,
7+
sanitizeTags,
8+
snapshot,
9+
start,
10+
type PerfSpan,
11+
} from "./index.js";
12+
13+
afterEach(() => {
14+
clear();
15+
});
16+
17+
describe("start / end / mark", () => {
18+
test("records a completed span with monotonic times", () => {
19+
const id = start("inference");
20+
expect(id.length).toBeGreaterThan(0);
21+
end(id);
22+
23+
const spans = snapshot();
24+
expect(spans).toHaveLength(1);
25+
const span = spans[0]!;
26+
expect(span.name).toBe("inference");
27+
expect(span.endNs).toBeDefined();
28+
expect(span.endNs! >= span.startNs).toBe(true);
29+
});
30+
31+
test("nests via parentId", () => {
32+
const turnId = start("turn");
33+
const infId = start("inference", { parentId: turnId });
34+
end(infId);
35+
end(turnId);
36+
37+
const spans = snapshot();
38+
expect(spans).toHaveLength(2);
39+
const inference = spans.find((s) => s.name === "inference")!;
40+
const turn = spans.find((s) => s.name === "turn")!;
41+
expect(inference.parentId).toBe(turnId);
42+
expect(turn.parentId).toBeUndefined();
43+
});
44+
45+
test("mark is a completed point-in-time span", () => {
46+
const id = mark("adapter.transport", { transport: "http_sse" });
47+
expect(id.length).toBeGreaterThan(0);
48+
49+
const spans = snapshot();
50+
expect(spans).toHaveLength(1);
51+
expect(spans[0]!.startNs).toBe(spans[0]!.endNs!);
52+
expect(spans[0]!.tags).toEqual({ transport: "http_sse" });
53+
});
54+
55+
test("open spans appear in snapshot without endNs", () => {
56+
const id = start("session");
57+
const spans = snapshot();
58+
expect(spans).toHaveLength(1);
59+
expect(spans[0]!.id).toBe(id);
60+
expect(spans[0]!.endNs).toBeUndefined();
61+
});
62+
63+
test("unknown span names are ignored", () => {
64+
expect(start("not.a.phase")).toBe("");
65+
expect(mark("also.bad")).toBe("");
66+
expect(snapshot()).toHaveLength(0);
67+
});
68+
69+
test("end of unknown id is a no-op", () => {
70+
end("nope");
71+
end("");
72+
expect(snapshot()).toHaveLength(0);
73+
});
74+
75+
test("end merges sanitized tags onto the span", () => {
76+
const id = start("tool", { tags: { tool_id: "t1" } });
77+
end(id, { count: 3, prompt: "secret" });
78+
const span = snapshot()[0]!;
79+
expect(span.tags).toEqual({ tool_id: "t1", count: 3 });
80+
});
81+
});
82+
83+
describe("ring overflow", () => {
84+
test("drops oldest completed spans when capacity is exceeded", () => {
85+
// Fill past capacity with marks (cheap completed spans).
86+
for (let i = 0; i < RING_CAPACITY + 10; i += 1) {
87+
mark("tool", { count: i });
88+
}
89+
90+
const spans = snapshot();
91+
expect(spans).toHaveLength(RING_CAPACITY);
92+
93+
// Oldest surviving should be count = 10 (0..9 dropped).
94+
const first = spans[0]!;
95+
const last = spans[spans.length - 1]!;
96+
expect(first.tags?.count).toBe(10);
97+
expect(last.tags?.count).toBe(RING_CAPACITY + 9);
98+
});
99+
100+
test("clear empties ring and open spans", () => {
101+
start("session");
102+
mark("turn");
103+
clear();
104+
expect(snapshot()).toHaveLength(0);
105+
});
106+
});
107+
108+
describe("sanitizeTags", () => {
109+
test("keeps allowlisted enums, numbers, and opaque ids", () => {
110+
const tags = sanitizeTags({
111+
provider_id: "openai",
112+
model_id: "gpt-5.4",
113+
transport: "ws",
114+
duration_ms: 12.5,
115+
bytes: 1024,
116+
payload_bytes: 2048,
117+
count: 2,
118+
input_tokens: 100,
119+
output_tokens: 50,
120+
turn_id: "t1a2b3",
121+
subagent_id: "sa_9",
122+
tool_id: "call-01",
123+
});
124+
expect(tags).toEqual({
125+
provider_id: "openai",
126+
model_id: "gpt-5.4",
127+
transport: "ws",
128+
duration_ms: 12.5,
129+
bytes: 1024,
130+
payload_bytes: 2048,
131+
count: 2,
132+
input_tokens: 100,
133+
output_tokens: 50,
134+
turn_id: "t1a2b3",
135+
subagent_id: "sa_9",
136+
tool_id: "call-01",
137+
});
138+
});
139+
140+
test("strips free-text, paths, prompts, and unknown keys", () => {
141+
const tags = sanitizeTags({
142+
prompt: "system: you are a helpful assistant",
143+
path: "/Users/me/secret/repo/src/main.ts",
144+
error: "ENOENT: no such file or directory",
145+
message: "user said hello",
146+
stack: "Error\n at foo (/app/x.ts:1:1)",
147+
tool_args: JSON.stringify({ cmd: "rm -rf /" }),
148+
completion: "sure, here is the code",
149+
repo: "abklabs/corbits-code",
150+
unknown_key: "whatever",
151+
// also invalid values on allowed keys
152+
transport: "grpc",
153+
model_id: "has spaces and /path",
154+
provider_id: "a".repeat(100),
155+
duration_ms: Number.NaN,
156+
count: Infinity,
157+
bytes: "not-a-number",
158+
});
159+
expect(tags).toBeUndefined();
160+
});
161+
162+
test("returns undefined for null, undefined, or empty input", () => {
163+
expect(sanitizeTags(undefined)).toBeUndefined();
164+
expect(sanitizeTags(null)).toBeUndefined();
165+
expect(sanitizeTags({})).toBeUndefined();
166+
});
167+
168+
test("strips path-like opaque ids", () => {
169+
expect(sanitizeTags({ turn_id: "../../etc/passwd" })).toBeUndefined();
170+
expect(sanitizeTags({ model_id: "C:\\Windows\\System32" })).toBeUndefined();
171+
});
172+
});
173+
174+
describe("open/close budget", () => {
175+
test("start+end stays well under 50µs average", () => {
176+
// Warm up JIT / maps.
177+
for (let i = 0; i < 200; i += 1) {
178+
const id = start("inference");
179+
end(id);
180+
}
181+
clear();
182+
183+
const iterations = 5_000;
184+
const t0 = process.hrtime.bigint();
185+
for (let i = 0; i < iterations; i += 1) {
186+
const id = start("inference", { tags: { provider_id: "openai", model_id: "gpt-5.4" } });
187+
end(id, { duration_ms: 1 });
188+
}
189+
const t1 = process.hrtime.bigint();
190+
const avgNs = Number(t1 - t0) / iterations;
191+
// Budget: open/close on the order of microseconds. 50µs avg is a loose
192+
// ceiling that still fails if we regress into heavy work (I/O, crypto, etc.).
193+
expect(avgNs).toBeLessThan(50_000);
194+
});
195+
});
196+
197+
describe("snapshot shape", () => {
198+
test("completed spans retain only allowlisted fields", () => {
199+
const id = start("adapter.request_build", {
200+
parentId: "parent1",
201+
tags: {
202+
transport: "http_sse",
203+
payload_bytes: 4096,
204+
prompt: "DROP ME",
205+
},
206+
});
207+
end(id);
208+
209+
const span: PerfSpan = snapshot()[0]!;
210+
expect(Object.keys(span).sort()).toEqual(["endNs", "id", "name", "parentId", "startNs", "tags"].sort());
211+
expect(span.tags).toEqual({ transport: "http_sse", payload_bytes: 4096 });
212+
expect(span.parentId).toBe("parent1");
213+
});
214+
});

src/perf/index.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Always-on local performance tracing.
3+
*
4+
* Fixed-size ring buffer, monotonic high-res clocks, privacy-sanitized tags.
5+
* No network, no PostHog, no export side effects.
6+
*/
7+
8+
import { sanitizeTags, type PerfTags, type TransportKind } from "./sanitize.js";
9+
10+
export {
11+
sanitizeTags,
12+
ALLOWED_TAG_KEYS,
13+
type AllowedTagKey,
14+
type PerfTags,
15+
type TransportKind,
16+
} from "./sanitize.js";
17+
18+
/** Core + adapter phase names. Adapters extend; they do not invent new sinks. */
19+
export const SPAN_NAMES = [
20+
"session",
21+
"turn",
22+
"inference",
23+
"inference.ttft",
24+
"inference.stream",
25+
"tool",
26+
"permission.wait",
27+
"subagent",
28+
"adapter.request_build",
29+
"adapter.first_byte",
30+
"adapter.transport",
31+
] as const;
32+
33+
export type SpanName = (typeof SPAN_NAMES)[number];
34+
35+
const SPAN_NAME_SET: ReadonlySet<string> = new Set(SPAN_NAMES);
36+
37+
export type PerfSpan = {
38+
id: string;
39+
name: SpanName;
40+
parentId?: string;
41+
startNs: bigint;
42+
endNs?: bigint;
43+
tags?: PerfTags;
44+
};
45+
46+
export type StartOptions = {
47+
parentId?: string;
48+
tags?: Record<string, unknown>;
49+
};
50+
51+
/** Fixed ring capacity — constant, not a settings UI. */
52+
export const RING_CAPACITY = 4096;
53+
54+
// Module state: one process-wide ring. Tests call clear() between cases.
55+
let nextId = 0;
56+
const openSpans = new Map<string, PerfSpan>();
57+
const ring: (PerfSpan | undefined)[] = new Array(RING_CAPACITY);
58+
let ringWrite = 0;
59+
let ringCount = 0;
60+
61+
function nowNs(): bigint {
62+
return process.hrtime.bigint();
63+
}
64+
65+
function allocId(): string {
66+
nextId += 1;
67+
// Base-36 counter keeps ids short and allocation cheap (budget: microseconds).
68+
return nextId.toString(36);
69+
}
70+
71+
function isSpanName(name: string): name is SpanName {
72+
return SPAN_NAME_SET.has(name);
73+
}
74+
75+
function pushRing(span: PerfSpan): void {
76+
ring[ringWrite] = span;
77+
ringWrite = (ringWrite + 1) % RING_CAPACITY;
78+
if (ringCount < RING_CAPACITY) {
79+
ringCount += 1;
80+
}
81+
}
82+
83+
/**
84+
* Open a timed span. Returns an opaque id for `end`.
85+
* Unknown span names are ignored (returns empty string; end is a no-op).
86+
*/
87+
export function start(name: SpanName | string, opts?: StartOptions): string {
88+
if (!isSpanName(name)) return "";
89+
90+
const id = allocId();
91+
const tags = sanitizeTags(opts?.tags);
92+
const span: PerfSpan = {
93+
id,
94+
name,
95+
startNs: nowNs(),
96+
};
97+
if (opts?.parentId !== undefined && opts.parentId.length > 0) {
98+
span.parentId = opts.parentId;
99+
}
100+
if (tags !== undefined) {
101+
span.tags = tags;
102+
}
103+
openSpans.set(id, span);
104+
return id;
105+
}
106+
107+
/**
108+
* Close a span opened by `start`. Merges optional end tags (sanitized).
109+
* Unknown or already-ended ids are ignored.
110+
*/
111+
export function end(id: string, tags?: Record<string, unknown>): void {
112+
if (id.length === 0) return;
113+
const span = openSpans.get(id);
114+
if (span === undefined) return;
115+
116+
openSpans.delete(id);
117+
span.endNs = nowNs();
118+
119+
const endTags = sanitizeTags(tags);
120+
if (endTags !== undefined) {
121+
span.tags = span.tags === undefined ? endTags : { ...span.tags, ...endTags };
122+
}
123+
124+
pushRing(span);
125+
}
126+
127+
/**
128+
* Point-in-time event: recorded as a completed span with startNs === endNs.
129+
* Unknown span names are ignored.
130+
*/
131+
export function mark(name: SpanName | string, tags?: Record<string, unknown>): string {
132+
if (!isSpanName(name)) return "";
133+
134+
const id = allocId();
135+
const ns = nowNs();
136+
const sanitized = sanitizeTags(tags);
137+
const span: PerfSpan = {
138+
id,
139+
name,
140+
startNs: ns,
141+
endNs: ns,
142+
};
143+
if (sanitized !== undefined) {
144+
span.tags = sanitized;
145+
}
146+
pushRing(span);
147+
return id;
148+
}
149+
150+
/**
151+
* Snapshot of completed spans in chronological order (oldest first),
152+
* plus any still-open spans (endNs unset) appended after completed ones.
153+
*/
154+
export function snapshot(): PerfSpan[] {
155+
const completed: PerfSpan[] = [];
156+
if (ringCount > 0) {
157+
const startIdx = ringCount < RING_CAPACITY ? 0 : ringWrite;
158+
for (let i = 0; i < ringCount; i += 1) {
159+
const span = ring[(startIdx + i) % RING_CAPACITY];
160+
if (span !== undefined) completed.push(span);
161+
}
162+
}
163+
164+
if (openSpans.size === 0) return completed;
165+
166+
const open = [...openSpans.values()];
167+
// Stable order by start time so tests and dumps are deterministic.
168+
open.sort((a, b) => (a.startNs < b.startNs ? -1 : a.startNs > b.startNs ? 1 : 0));
169+
return completed.concat(open);
170+
}
171+
172+
/** Drop all spans (open + ring). For tests only. */
173+
export function clear(): void {
174+
openSpans.clear();
175+
for (let i = 0; i < RING_CAPACITY; i += 1) {
176+
ring[i] = undefined;
177+
}
178+
ringWrite = 0;
179+
ringCount = 0;
180+
nextId = 0;
181+
}

0 commit comments

Comments
 (0)