fix(telemetry): keep the first 20 miss causes instead of refusing the post

last_miss_cause.causes was z.array(z.unknown()).max(20), so a statusline
payload carrying more than 20 causes failed validation and lost the
whole post: plan usage, model, cache and the footer with it. Truncate
to the first 20 with a preprocess that slices before the array
validates, so an over-long array costs one bounded slice rather than
per-element work across every entry.
This commit is contained in:
JD committed 2026-10-13 20:45:13 -04:00
1 parent 4091378e15
commit 8bf581cc55
2 files changed
+24 -1

No files matched your search

+10 -1
View File
@@ -707,7 +707,16 @@ export const StatusTelemetrySchema = z.object({
miss_recache_tokens: z.number().nullish(),
last_miss_at: z.number().nullish(),
recache_tokens_if_cold: z.number().nullish(),
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
// slice, never per-element validation across every entry (parsePromptCache then keeps
// the string ones among those 20).
last_miss_cause: z
.object({
causes: z.preprocess((v) => (Array.isArray(v) ? v.slice(0, 20) : v), z.array(z.unknown()).nullish()),
})
.nullish(),
})
.nullish(),
})
@@ -154,6 +154,20 @@ describe('POST /api/status-telemetry', () => {
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1);
});
it('keeps the first 20 miss causes instead of refusing a post that carries more', async () => {
const causes = Array.from({ length: 25 }, (_, i) => `cause_${i}`);
const res = await post({
sessionId: SID,
data: { ...REAL, prompt_cache: { warm: false, ttl: '1h', last_miss_cause: { causes } } },
});
// Accepted, not 400: an over-long causes array must not blank the whole statusline.
expect(res.statusCode).toBe(200);
const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { lastMissCauses?: string[] } | null };
expect(session.promptCache?.lastMissCauses).toHaveLength(20);
expect(session.promptCache?.lastMissCauses?.[0]).toBe('cause_0');
expect(session.promptCache?.lastMissCauses?.[19]).toBe('cause_19');
});
it('rejects a malformed body (missing sessionId) with 400', async () => {
const res = await post({ data: REAL });
expect(res.statusCode).toBe(400);