diff --git a/PROOF.md b/PROOF.md index b47bb4d..30e9a7e 100644 --- a/PROOF.md +++ b/PROOF.md @@ -131,7 +131,7 @@ to the trailing window; (4) leaked snapshot timers on unmount → removed. All p ## 5. M2 — drill-down (click a comet → its real span, verified against `/traces/:id`) Clicking a comet resolves it to the correct span and shows that span's **real attributes**. The pick is -pure CPU math (`app/src/gpu/motion.ts`) that mirrors the shader's closed-form motion (`shaders/river.wgsl`) +pure CPU math (`app/src/gpu/motion.ts`) that mirrors the shader's closed-form motion (`shaders/river-sim.wgsl`; M4 moved the motion authority here from `river.wgsl`, which is now the draw shader) and inverts a screen click back to the nearest comet head; the server now retains each span's raw attributes and serves them at `GET /traces/:id` (the SSE stream stays lean — attributes are fetched on demand, not streamed). diff --git a/README.md b/README.md index 27d9ce1..d6453d0 100644 --- a/README.md +++ b/README.md @@ -135,4 +135,4 @@ develop the renderer. - ffmpeg isn't required; without it you get `.webm` (plays everywhere). With it, `record.mjs` also emits `.mp4` + `.gif`. ## Requirements -node ≥ 20 · python 3 (for `npm run serve`) · a WebGPU-capable Chrome/Edge. See [`CLAUDE.md`](./CLAUDE.md). +node ≥ 20 · python 3 (for `npm run serve:spike`) · a WebGPU-capable Chrome/Edge. See [`CLAUDE.md`](./CLAUDE.md). diff --git a/ingest.mjs b/ingest.mjs index 39a0179..a92419d 100644 --- a/ingest.mjs +++ b/ingest.mjs @@ -33,7 +33,7 @@ const events = sorted.map((r) => { cacheHit: r.cacheHit === true, fallbackUsed: r.fallbackUsed === true, guardrail: r.guardrailStatus || null, // 'pass' | 'flag' | 'block' - pii: piiCategories.length > 0, + pii: piiCategories.length > 0 || r.guardrailStatus === 'block', // match server mappers (otlp.ts/poller.ts) piiCategories, }; }); diff --git a/server/src/index.ts b/server/src/index.ts index dbb42a2..7c1a07f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,10 +17,18 @@ import { startPoller } from './poller'; const PORT = Number(process.env.PORT ?? 4319); const hub = new Hub(Number(process.env.BUFFER ?? 2000)); +const MAX_BODY = 8 * 1024 * 1024; // 8 MB cap on the OTLP POST body — the only network-facing write path +class BodyTooLarge extends Error {} + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let b = ''; - req.on('data', (c) => (b += c)); + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > MAX_BODY) { req.destroy(); reject(new BodyTooLarge('request body too large')); return; } + b += c; + }); req.on('end', () => resolve(b)); req.on('error', reject); }); @@ -47,7 +55,7 @@ const server = createServer(async (req, res) => { res.end('{}'); if (events.length) console.log(`[otlp] +${events.length} span(s) buffer=${hub.size} clients=${hub.clientCount}`); } catch (e) { - res.writeHead(400, { 'content-type': 'application/json' }); + res.writeHead(e instanceof BodyTooLarge ? 413 : 400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: String(e) })); } return; diff --git a/shared/schema.ts b/shared/schema.ts index 21de098..79c03b8 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -10,7 +10,11 @@ export type Guardrail = 'pass' | 'flag' | 'block' | null; export interface TraceEvent { /** stable id (span id) — optional until the OTLP/poller path fills it (M1/M2 drill-down). */ id?: string; - /** ms from the first event in the trace. */ + /** + * File/replay path: ms from the first event in the trace (`ingest.mjs` relativizes to t0). + * Live server path (OTLP/poller): absolute epoch ms — there is no single trace t0 in a + * streaming ring buffer; live consumers position comets by arrival time, not `t`. + */ t: number; model: string | null; provider: string | null;