From 32b8c274b055fb2d4cbfed20c83f1049554c4813 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 3 Aug 2026 22:26:02 -0700 Subject: [PATCH 1/2] 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. --- src/perf/index.test.ts | 214 +++++++++++++++++++++++++++++++++++++++++ src/perf/index.ts | 181 ++++++++++++++++++++++++++++++++++ src/perf/sanitize.ts | 125 ++++++++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 src/perf/index.test.ts create mode 100644 src/perf/index.ts create mode 100644 src/perf/sanitize.ts diff --git a/src/perf/index.test.ts b/src/perf/index.test.ts new file mode 100644 index 000000000..9c753426c --- /dev/null +++ b/src/perf/index.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + RING_CAPACITY, + clear, + end, + mark, + sanitizeTags, + snapshot, + start, + type PerfSpan, +} from "./index.js"; + +afterEach(() => { + clear(); +}); + +describe("start / end / mark", () => { + test("records a completed span with monotonic times", () => { + const id = start("inference"); + expect(id.length).toBeGreaterThan(0); + end(id); + + const spans = snapshot(); + expect(spans).toHaveLength(1); + const span = spans[0]!; + expect(span.name).toBe("inference"); + expect(span.endNs).toBeDefined(); + expect(span.endNs! >= span.startNs).toBe(true); + }); + + test("nests via parentId", () => { + const turnId = start("turn"); + const infId = start("inference", { parentId: turnId }); + end(infId); + end(turnId); + + const spans = snapshot(); + expect(spans).toHaveLength(2); + const inference = spans.find((s) => s.name === "inference")!; + const turn = spans.find((s) => s.name === "turn")!; + expect(inference.parentId).toBe(turnId); + expect(turn.parentId).toBeUndefined(); + }); + + test("mark is a completed point-in-time span", () => { + const id = mark("adapter.transport", { transport: "http_sse" }); + expect(id.length).toBeGreaterThan(0); + + const spans = snapshot(); + expect(spans).toHaveLength(1); + expect(spans[0]!.startNs).toBe(spans[0]!.endNs!); + expect(spans[0]!.tags).toEqual({ transport: "http_sse" }); + }); + + test("open spans appear in snapshot without endNs", () => { + const id = start("session"); + const spans = snapshot(); + expect(spans).toHaveLength(1); + expect(spans[0]!.id).toBe(id); + expect(spans[0]!.endNs).toBeUndefined(); + }); + + test("unknown span names are ignored", () => { + expect(start("not.a.phase")).toBe(""); + expect(mark("also.bad")).toBe(""); + expect(snapshot()).toHaveLength(0); + }); + + test("end of unknown id is a no-op", () => { + end("nope"); + end(""); + expect(snapshot()).toHaveLength(0); + }); + + test("end merges sanitized tags onto the span", () => { + const id = start("tool", { tags: { tool_id: "t1" } }); + end(id, { count: 3, prompt: "secret" }); + const span = snapshot()[0]!; + expect(span.tags).toEqual({ tool_id: "t1", count: 3 }); + }); +}); + +describe("ring overflow", () => { + test("drops oldest completed spans when capacity is exceeded", () => { + // Fill past capacity with marks (cheap completed spans). + for (let i = 0; i < RING_CAPACITY + 10; i += 1) { + mark("tool", { count: i }); + } + + const spans = snapshot(); + expect(spans).toHaveLength(RING_CAPACITY); + + // Oldest surviving should be count = 10 (0..9 dropped). + const first = spans[0]!; + const last = spans[spans.length - 1]!; + expect(first.tags?.count).toBe(10); + expect(last.tags?.count).toBe(RING_CAPACITY + 9); + }); + + test("clear empties ring and open spans", () => { + start("session"); + mark("turn"); + clear(); + expect(snapshot()).toHaveLength(0); + }); +}); + +describe("sanitizeTags", () => { + test("keeps allowlisted enums, numbers, and opaque ids", () => { + const tags = sanitizeTags({ + provider_id: "openai", + model_id: "gpt-5.4", + transport: "ws", + duration_ms: 12.5, + bytes: 1024, + payload_bytes: 2048, + count: 2, + input_tokens: 100, + output_tokens: 50, + turn_id: "t1a2b3", + subagent_id: "sa_9", + tool_id: "call-01", + }); + expect(tags).toEqual({ + provider_id: "openai", + model_id: "gpt-5.4", + transport: "ws", + duration_ms: 12.5, + bytes: 1024, + payload_bytes: 2048, + count: 2, + input_tokens: 100, + output_tokens: 50, + turn_id: "t1a2b3", + subagent_id: "sa_9", + tool_id: "call-01", + }); + }); + + test("strips free-text, paths, prompts, and unknown keys", () => { + const tags = sanitizeTags({ + prompt: "system: you are a helpful assistant", + path: "/Users/me/secret/repo/src/main.ts", + error: "ENOENT: no such file or directory", + message: "user said hello", + stack: "Error\n at foo (/app/x.ts:1:1)", + tool_args: JSON.stringify({ cmd: "rm -rf /" }), + completion: "sure, here is the code", + repo: "abklabs/corbits-code", + unknown_key: "whatever", + // also invalid values on allowed keys + transport: "grpc", + model_id: "has spaces and /path", + provider_id: "a".repeat(100), + duration_ms: Number.NaN, + count: Infinity, + bytes: "not-a-number", + }); + expect(tags).toBeUndefined(); + }); + + test("returns undefined for null, undefined, or empty input", () => { + expect(sanitizeTags(undefined)).toBeUndefined(); + expect(sanitizeTags(null)).toBeUndefined(); + expect(sanitizeTags({})).toBeUndefined(); + }); + + test("strips path-like opaque ids", () => { + expect(sanitizeTags({ turn_id: "../../etc/passwd" })).toBeUndefined(); + expect(sanitizeTags({ model_id: "C:\\Windows\\System32" })).toBeUndefined(); + }); +}); + +describe("open/close budget", () => { + test("start+end stays well under 50µs average", () => { + // Warm up JIT / maps. + for (let i = 0; i < 200; i += 1) { + const id = start("inference"); + end(id); + } + clear(); + + const iterations = 5_000; + const t0 = process.hrtime.bigint(); + for (let i = 0; i < iterations; i += 1) { + const id = start("inference", { tags: { provider_id: "openai", model_id: "gpt-5.4" } }); + end(id, { duration_ms: 1 }); + } + const t1 = process.hrtime.bigint(); + const avgNs = Number(t1 - t0) / iterations; + // Budget: open/close on the order of microseconds. 50µs avg is a loose + // ceiling that still fails if we regress into heavy work (I/O, crypto, etc.). + expect(avgNs).toBeLessThan(50_000); + }); +}); + +describe("snapshot shape", () => { + test("completed spans retain only allowlisted fields", () => { + const id = start("adapter.request_build", { + parentId: "parent1", + tags: { + transport: "http_sse", + payload_bytes: 4096, + prompt: "DROP ME", + }, + }); + end(id); + + const span: PerfSpan = snapshot()[0]!; + expect(Object.keys(span).sort()).toEqual(["endNs", "id", "name", "parentId", "startNs", "tags"].sort()); + expect(span.tags).toEqual({ transport: "http_sse", payload_bytes: 4096 }); + expect(span.parentId).toBe("parent1"); + }); +}); diff --git a/src/perf/index.ts b/src/perf/index.ts new file mode 100644 index 000000000..f49991ad5 --- /dev/null +++ b/src/perf/index.ts @@ -0,0 +1,181 @@ +/** + * Always-on local performance tracing. + * + * Fixed-size ring buffer, monotonic high-res clocks, privacy-sanitized tags. + * No network, no PostHog, no export side effects. + */ + +import { sanitizeTags, type PerfTags, type TransportKind } from "./sanitize.js"; + +export { + sanitizeTags, + ALLOWED_TAG_KEYS, + type AllowedTagKey, + type PerfTags, + type TransportKind, +} from "./sanitize.js"; + +/** Core + adapter phase names. Adapters extend; they do not invent new sinks. */ +export const SPAN_NAMES = [ + "session", + "turn", + "inference", + "inference.ttft", + "inference.stream", + "tool", + "permission.wait", + "subagent", + "adapter.request_build", + "adapter.first_byte", + "adapter.transport", +] as const; + +export type SpanName = (typeof SPAN_NAMES)[number]; + +const SPAN_NAME_SET: ReadonlySet = new Set(SPAN_NAMES); + +export type PerfSpan = { + id: string; + name: SpanName; + parentId?: string; + startNs: bigint; + endNs?: bigint; + tags?: PerfTags; +}; + +export type StartOptions = { + parentId?: string; + tags?: Record; +}; + +/** Fixed ring capacity — constant, not a settings UI. */ +export const RING_CAPACITY = 4096; + +// Module state: one process-wide ring. Tests call clear() between cases. +let nextId = 0; +const openSpans = new Map(); +const ring: (PerfSpan | undefined)[] = new Array(RING_CAPACITY); +let ringWrite = 0; +let ringCount = 0; + +function nowNs(): bigint { + return process.hrtime.bigint(); +} + +function allocId(): string { + nextId += 1; + // Base-36 counter keeps ids short and allocation cheap (budget: microseconds). + return nextId.toString(36); +} + +function isSpanName(name: string): name is SpanName { + return SPAN_NAME_SET.has(name); +} + +function pushRing(span: PerfSpan): void { + ring[ringWrite] = span; + ringWrite = (ringWrite + 1) % RING_CAPACITY; + if (ringCount < RING_CAPACITY) { + ringCount += 1; + } +} + +/** + * Open a timed span. Returns an opaque id for `end`. + * Unknown span names are ignored (returns empty string; end is a no-op). + */ +export function start(name: SpanName | string, opts?: StartOptions): string { + if (!isSpanName(name)) return ""; + + const id = allocId(); + const tags = sanitizeTags(opts?.tags); + const span: PerfSpan = { + id, + name, + startNs: nowNs(), + }; + if (opts?.parentId !== undefined && opts.parentId.length > 0) { + span.parentId = opts.parentId; + } + if (tags !== undefined) { + span.tags = tags; + } + openSpans.set(id, span); + return id; +} + +/** + * Close a span opened by `start`. Merges optional end tags (sanitized). + * Unknown or already-ended ids are ignored. + */ +export function end(id: string, tags?: Record): void { + if (id.length === 0) return; + const span = openSpans.get(id); + if (span === undefined) return; + + openSpans.delete(id); + span.endNs = nowNs(); + + const endTags = sanitizeTags(tags); + if (endTags !== undefined) { + span.tags = span.tags === undefined ? endTags : { ...span.tags, ...endTags }; + } + + pushRing(span); +} + +/** + * Point-in-time event: recorded as a completed span with startNs === endNs. + * Unknown span names are ignored. + */ +export function mark(name: SpanName | string, tags?: Record): string { + if (!isSpanName(name)) return ""; + + const id = allocId(); + const ns = nowNs(); + const sanitized = sanitizeTags(tags); + const span: PerfSpan = { + id, + name, + startNs: ns, + endNs: ns, + }; + if (sanitized !== undefined) { + span.tags = sanitized; + } + pushRing(span); + return id; +} + +/** + * Snapshot of completed spans in chronological order (oldest first), + * plus any still-open spans (endNs unset) appended after completed ones. + */ +export function snapshot(): PerfSpan[] { + const completed: PerfSpan[] = []; + if (ringCount > 0) { + const startIdx = ringCount < RING_CAPACITY ? 0 : ringWrite; + for (let i = 0; i < ringCount; i += 1) { + const span = ring[(startIdx + i) % RING_CAPACITY]; + if (span !== undefined) completed.push(span); + } + } + + if (openSpans.size === 0) return completed; + + const open = [...openSpans.values()]; + // Stable order by start time so tests and dumps are deterministic. + open.sort((a, b) => (a.startNs < b.startNs ? -1 : a.startNs > b.startNs ? 1 : 0)); + return completed.concat(open); +} + +/** Drop all spans (open + ring). For tests only. */ +export function clear(): void { + openSpans.clear(); + for (let i = 0; i < RING_CAPACITY; i += 1) { + ring[i] = undefined; + } + ringWrite = 0; + ringCount = 0; + nextId = 0; +} diff --git a/src/perf/sanitize.ts b/src/perf/sanitize.ts new file mode 100644 index 000000000..9cf4f2861 --- /dev/null +++ b/src/perf/sanitize.ts @@ -0,0 +1,125 @@ +/** + * Privacy fence for PerfTrace tags. + * + * Allowed: phase-related enums, provider/model ids, numeric durations/bytes/counts, + * transport enum, short opaque ids. + * Forbidden: prompts, completions, tool args, paths, free-text errors, stack traces. + */ + +export type TransportKind = "http_sse" | "ws"; + +/** Tag keys that may appear on a span. Everything else is stripped. */ +export const ALLOWED_TAG_KEYS = [ + "provider_id", + "model_id", + "transport", + "duration_ms", + "duration_ns", + "bytes", + "payload_bytes", + "count", + "input_tokens", + "output_tokens", + "turn_id", + "subagent_id", + "tool_id", +] as const; + +export type AllowedTagKey = (typeof ALLOWED_TAG_KEYS)[number]; + +export type PerfTags = Partial<{ + provider_id: string; + model_id: string; + transport: TransportKind; + duration_ms: number; + duration_ns: number; + bytes: number; + payload_bytes: number; + count: number; + input_tokens: number; + output_tokens: number; + turn_id: string; + subagent_id: string; + tool_id: string; +}>; + +const ALLOWED_KEY_SET: ReadonlySet = new Set(ALLOWED_TAG_KEYS); + +const TRANSPORT_VALUES: ReadonlySet = new Set(["http_sse", "ws"]); + +/** Numeric tag keys — only finite numbers are kept. */ +const NUMERIC_KEYS: ReadonlySet = new Set([ + "duration_ms", + "duration_ns", + "bytes", + "payload_bytes", + "count", + "input_tokens", + "output_tokens", +]); + +/** Opaque-id / model-id keys: short, no path separators or whitespace. */ +const ID_KEYS: ReadonlySet = new Set([ + "provider_id", + "model_id", + "turn_id", + "subagent_id", + "tool_id", +]); + +// Caps free-form id length so a dumped prompt never sneaks in as a "model_id". +const MAX_ID_LENGTH = 64; + +// Opaque ids / model ids: alphanumerics, dots, underscores, hyphens, colons, @. +// No spaces, slashes, backslashes, or control characters. +const OPAQUE_ID_RE = /^[A-Za-z0-9._:@-]{1,64}$/; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isOpaqueId(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_ID_LENGTH && OPAQUE_ID_RE.test(value); +} + +/** + * Strip unknown keys and non-allowlisted values. + * Never throws; returns a new object with only safe tags (or undefined if empty). + */ +export function sanitizeTags(tags: Record | undefined | null): PerfTags | undefined { + if (tags === undefined || tags === null) return undefined; + + const out: PerfTags = {}; + let kept = 0; + + for (const key of Object.keys(tags)) { + if (!ALLOWED_KEY_SET.has(key)) continue; + const allowedKey = key as AllowedTagKey; + const value = tags[key]; + + if (allowedKey === "transport") { + if (typeof value === "string" && TRANSPORT_VALUES.has(value)) { + out.transport = value as TransportKind; + kept += 1; + } + continue; + } + + if (NUMERIC_KEYS.has(allowedKey)) { + if (isFiniteNumber(value)) { + (out as Record)[allowedKey] = value; + kept += 1; + } + continue; + } + + if (ID_KEYS.has(allowedKey)) { + if (isOpaqueId(value)) { + (out as Record)[allowedKey] = value; + kept += 1; + } + } + } + + return kept === 0 ? undefined : out; +} From 8575c45bd1d27424c15c135d8acae3bcdb6ced8d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 4 Aug 2026 09:15:51 -0700 Subject: [PATCH 2/2] Harden PerfTrace privacy fence, snapshot immutability, open-span cap Accept parentId only as opaque span ids (known open/ring or OPAQUE_ID_RE); return shallow snapshot copies; cap open spans with oldest-first eviction; mark() takes StartOptions; cover double-end, clear, and overflow paths. --- src/perf/index.test.ts | 136 ++++++++++++++++++++++++++++++++++++++++- src/perf/index.ts | 85 +++++++++++++++++++++++--- src/perf/sanitize.ts | 5 +- 3 files changed, 212 insertions(+), 14 deletions(-) diff --git a/src/perf/index.test.ts b/src/perf/index.test.ts index 9c753426c..d226e3a21 100644 --- a/src/perf/index.test.ts +++ b/src/perf/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { + OPEN_SPAN_CAPACITY, RING_CAPACITY, clear, end, @@ -43,7 +44,7 @@ describe("start / end / mark", () => { }); test("mark is a completed point-in-time span", () => { - const id = mark("adapter.transport", { transport: "http_sse" }); + const id = mark("adapter.transport", { tags: { transport: "http_sse" } }); expect(id.length).toBeGreaterThan(0); const spans = snapshot(); @@ -52,6 +53,17 @@ describe("start / end / mark", () => { expect(spans[0]!.tags).toEqual({ transport: "http_sse" }); }); + test("mark accepts optional parentId like start", () => { + const turnId = start("turn"); + mark("adapter.transport", { parentId: turnId, tags: { transport: "ws" } }); + end(turnId); + + const spans = snapshot(); + const marked = spans.find((s) => s.name === "adapter.transport")!; + expect(marked.parentId).toBe(turnId); + expect(marked.tags).toEqual({ transport: "ws" }); + }); + test("open spans appear in snapshot without endNs", () => { const id = start("session"); const spans = snapshot(); @@ -72,6 +84,20 @@ describe("start / end / mark", () => { expect(snapshot()).toHaveLength(0); }); + test("double-end is a no-op", () => { + const id = start("tool"); + end(id); + end(id); + expect(snapshot()).toHaveLength(1); + }); + + test("end after clear is a no-op", () => { + const id = start("tool"); + clear(); + end(id, { count: 1 }); + expect(snapshot()).toHaveLength(0); + }); + test("end merges sanitized tags onto the span", () => { const id = start("tool", { tags: { tool_id: "t1" } }); end(id, { count: 3, prompt: "secret" }); @@ -80,11 +106,93 @@ describe("start / end / mark", () => { }); }); +describe("parentId privacy fence", () => { + test("strips path-like parentId", () => { + const id = start("inference", { parentId: "/Users/me/secret/repo/src/main.ts" }); + end(id); + expect(snapshot()[0]!.parentId).toBeUndefined(); + }); + + test("strips free-text and path-separator parentIds", () => { + const a = start("tool", { parentId: "has spaces and free text" }); + end(a); + const b = start("tool", { parentId: "../../etc/passwd" }); + end(b); + const c = start("tool", { parentId: "C:\\Windows\\System32" }); + end(c); + + for (const span of snapshot()) { + expect(span.parentId).toBeUndefined(); + } + }); + + test("accepts known open span ids and opaque ids", () => { + const parent = start("turn"); + const child = start("inference", { parentId: parent }); + end(child); + end(parent); + + // Opaque id that matches OPAQUE_ID_RE but is not currently open/ring-known + // after clear of only that id — still accepted when pattern matches. + const orphan = start("tool", { parentId: "parent1" }); + end(orphan); + + const spans = snapshot(); + expect(spans.find((s) => s.name === "inference")!.parentId).toBe(parent); + expect(spans.find((s) => s.name === "tool")!.parentId).toBe("parent1"); + }); + + test("accepts parentId of a completed (ring) span", () => { + const parent = start("turn"); + end(parent); + const child = start("inference", { parentId: parent }); + end(child); + expect(snapshot().find((s) => s.name === "inference")!.parentId).toBe(parent); + }); +}); + +describe("snapshot immutability", () => { + test("mutating a snapshot does not poison the next snapshot", () => { + const id = start("tool", { tags: { tool_id: "t1", count: 1 } }); + end(id); + + const first = snapshot(); + expect(first).toHaveLength(1); + first[0]!.name = "session"; + first[0]!.tags!.tool_id = "POISON"; + first[0]!.tags!.count = 999; + first[0]!.parentId = "injected"; + first.push({ + id: "fake", + name: "session", + startNs: 0n, + endNs: 0n, + }); + + const second = snapshot(); + expect(second).toHaveLength(1); + expect(second[0]!.name).toBe("tool"); + expect(second[0]!.tags).toEqual({ tool_id: "t1", count: 1 }); + expect(second[0]!.parentId).toBeUndefined(); + }); + + test("mutating an open-span snapshot does not poison open state", () => { + start("session", { tags: { provider_id: "openai" } }); + const first = snapshot(); + first[0]!.tags!.provider_id = "POISON"; + first[0]!.endNs = 1n; + + const second = snapshot(); + expect(second[0]!.tags).toEqual({ provider_id: "openai" }); + expect(second[0]!.endNs).toBeUndefined(); + }); +}); + describe("ring overflow", () => { test("drops oldest completed spans when capacity is exceeded", () => { // Fill past capacity with marks (cheap completed spans). for (let i = 0; i < RING_CAPACITY + 10; i += 1) { - mark("tool", { count: i }); + mark("tool", { tags: { count: i } }); } const spans = snapshot(); @@ -105,6 +213,30 @@ describe("ring overflow", () => { }); }); +describe("open span capacity", () => { + test("evicts oldest open span when over OPEN_SPAN_CAPACITY", () => { + const ids: string[] = []; + for (let i = 0; i < OPEN_SPAN_CAPACITY + 5; i += 1) { + ids.push(start("tool", { tags: { count: i } })); + } + + const open = snapshot().filter((s) => s.endNs === undefined); + expect(open).toHaveLength(OPEN_SPAN_CAPACITY); + + // Oldest five (count 0..4) were evicted; survivors start at count 5. + const counts = open.map((s) => s.tags?.count).sort((a, b) => (a ?? 0) - (b ?? 0)); + expect(counts[0]).toBe(5); + expect(counts[counts.length - 1]).toBe(OPEN_SPAN_CAPACITY + 4); + + // Ending an evicted id is a no-op; ending a survivor still works. + end(ids[0]!); + end(ids[ids.length - 1]!); + const completed = snapshot().filter((s) => s.endNs !== undefined); + expect(completed).toHaveLength(1); + expect(completed[0]!.tags?.count).toBe(OPEN_SPAN_CAPACITY + 4); + }); +}); + describe("sanitizeTags", () => { test("keeps allowlisted enums, numbers, and opaque ids", () => { const tags = sanitizeTags({ diff --git a/src/perf/index.ts b/src/perf/index.ts index f49991ad5..0bd5f62b1 100644 --- a/src/perf/index.ts +++ b/src/perf/index.ts @@ -3,12 +3,19 @@ * * Fixed-size ring buffer, monotonic high-res clocks, privacy-sanitized tags. * No network, no PostHog, no export side effects. + * + * Memory policy (fixed, not settings): + * - RING_CAPACITY completed spans (oldest completed dropped on overflow) + * - OPEN_SPAN_CAPACITY concurrent open spans (oldest open dropped on overflow) + * - snapshot() returns shallow copies so consumers cannot poison internal state */ -import { sanitizeTags, type PerfTags, type TransportKind } from "./sanitize.js"; +import { isOpaqueId, sanitizeTags, type PerfTags } from "./sanitize.js"; export { sanitizeTags, + isOpaqueId, + OPAQUE_ID_RE, ALLOWED_TAG_KEYS, type AllowedTagKey, type PerfTags, @@ -48,9 +55,15 @@ export type StartOptions = { tags?: Record; }; -/** Fixed ring capacity — constant, not a settings UI. */ +/** Fixed ring capacity for completed spans — constant, not a settings UI. */ export const RING_CAPACITY = 4096; +/** + * Max concurrent open (un-ended) spans. Fixed memory: when exceeded, the + * oldest open span is dropped so a leaked start() cannot grow without bound. + */ +export const OPEN_SPAN_CAPACITY = 1024; + // Module state: one process-wide ring. Tests call clear() between cases. let nextId = 0; const openSpans = new Map(); @@ -80,33 +93,77 @@ function pushRing(span: PerfSpan): void { } } +function ringHasId(id: string): boolean { + if (ringCount === 0) return false; + const startIdx = ringCount < RING_CAPACITY ? 0 : ringWrite; + for (let i = 0; i < ringCount; i += 1) { + const span = ring[(startIdx + i) % RING_CAPACITY]; + if (span !== undefined && span.id === id) return true; + } + return false; +} + +/** + * Privacy fence for parentId: only opaque span ids (known open/ring ids, or + * OPAQUE_ID_RE). Free text, paths, and long strings are stripped. + */ +function sanitizeParentId(parentId: string | undefined): string | undefined { + if (parentId === undefined || parentId.length === 0) return undefined; + if (openSpans.has(parentId) || ringHasId(parentId)) return parentId; + if (isOpaqueId(parentId)) return parentId; + return undefined; +} + +/** Shallow copy so snapshot consumers cannot mutate the ring / open map. */ +function cloneSpan(span: PerfSpan): PerfSpan { + const copy: PerfSpan = { + id: span.id, + name: span.name, + startNs: span.startNs, + }; + if (span.parentId !== undefined) copy.parentId = span.parentId; + if (span.endNs !== undefined) copy.endNs = span.endNs; + if (span.tags !== undefined) copy.tags = { ...span.tags }; + return copy; +} + +/** Drop the oldest open span when at capacity (Map iteration is insertion order). */ +function evictOldestOpenIfFull(): void { + if (openSpans.size < OPEN_SPAN_CAPACITY) return; + const oldest = openSpans.keys().next().value; + if (oldest !== undefined) openSpans.delete(oldest); +} + /** * Open a timed span. Returns an opaque id for `end`. * Unknown span names are ignored (returns empty string; end is a no-op). + * When open capacity is full, the oldest open span is dropped first. */ export function start(name: SpanName | string, opts?: StartOptions): string { if (!isSpanName(name)) return ""; const id = allocId(); const tags = sanitizeTags(opts?.tags); + const parentId = sanitizeParentId(opts?.parentId); const span: PerfSpan = { id, name, startNs: nowNs(), }; - if (opts?.parentId !== undefined && opts.parentId.length > 0) { - span.parentId = opts.parentId; + if (parentId !== undefined) { + span.parentId = parentId; } if (tags !== undefined) { span.tags = tags; } + evictOldestOpenIfFull(); openSpans.set(id, span); return id; } /** * Close a span opened by `start`. Merges optional end tags (sanitized). - * Unknown or already-ended ids are ignored. + * Unknown or already-ended ids are ignored (double-end is a no-op). */ export function end(id: string, tags?: Record): void { if (id.length === 0) return; @@ -126,20 +183,25 @@ export function end(id: string, tags?: Record): void { /** * Point-in-time event: recorded as a completed span with startNs === endNs. - * Unknown span names are ignored. + * Unknown span names are ignored. Accepts the same options shape as `start` + * (optional parentId + tags). */ -export function mark(name: SpanName | string, tags?: Record): string { +export function mark(name: SpanName | string, opts?: StartOptions): string { if (!isSpanName(name)) return ""; const id = allocId(); const ns = nowNs(); - const sanitized = sanitizeTags(tags); + const sanitized = sanitizeTags(opts?.tags); + const parentId = sanitizeParentId(opts?.parentId); const span: PerfSpan = { id, name, startNs: ns, endNs: ns, }; + if (parentId !== undefined) { + span.parentId = parentId; + } if (sanitized !== undefined) { span.tags = sanitized; } @@ -150,6 +212,9 @@ export function mark(name: SpanName | string, tags?: Record): s /** * Snapshot of completed spans in chronological order (oldest first), * plus any still-open spans (endNs unset) appended after completed ones. + * + * Returns shallow copies of each span (and of tags) so callers cannot + * mutate the ring buffer or open-span map through the returned objects. */ export function snapshot(): PerfSpan[] { const completed: PerfSpan[] = []; @@ -157,13 +222,13 @@ export function snapshot(): PerfSpan[] { const startIdx = ringCount < RING_CAPACITY ? 0 : ringWrite; for (let i = 0; i < ringCount; i += 1) { const span = ring[(startIdx + i) % RING_CAPACITY]; - if (span !== undefined) completed.push(span); + if (span !== undefined) completed.push(cloneSpan(span)); } } if (openSpans.size === 0) return completed; - const open = [...openSpans.values()]; + const open = [...openSpans.values()].map(cloneSpan); // Stable order by start time so tests and dumps are deterministic. open.sort((a, b) => (a.startNs < b.startNs ? -1 : a.startNs > b.startNs ? 1 : 0)); return completed.concat(open); diff --git a/src/perf/sanitize.ts b/src/perf/sanitize.ts index 9cf4f2861..2a08d2ac8 100644 --- a/src/perf/sanitize.ts +++ b/src/perf/sanitize.ts @@ -72,13 +72,14 @@ const MAX_ID_LENGTH = 64; // Opaque ids / model ids: alphanumerics, dots, underscores, hyphens, colons, @. // No spaces, slashes, backslashes, or control characters. -const OPAQUE_ID_RE = /^[A-Za-z0-9._:@-]{1,64}$/; +export const OPAQUE_ID_RE = /^[A-Za-z0-9._:@-]{1,64}$/; function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function isOpaqueId(value: unknown): value is string { +/** True for short opaque id strings (no paths, whitespace, or free text). */ +export function isOpaqueId(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_ID_LENGTH && OPAQUE_ID_RE.test(value); }