Files
pxpipe/tests/tracker.test.ts
T
teamchong c1d111a083 feat(transform): per-bucket char attribution telemetry (Task #18 Phase 1)
Adds the data collection half of the adaptive chars-per-token plan
without touching the gate. Every isCompressionProfitable call site
now credits its chars to a named bucket on TransformInfo, and those
counts flow through toTrackEvent into the persisted JSONL.

transform.ts:
- New BucketName union: 'static_slab' | 'reminder' |
  'tool_result_{structured,log,prose}' | 'history'
- bumpBucket() helper lazily allocates info.bucketChars
- toolResultBucket() routes by classifyContent shape so the post-
  compaction text we credit matches the shape the gate evaluated
- Bump sites: static slab (combinedRaw), reminder (reminderRaw),
  tool_result single-string (innerRaw), tool_result multi-block
  per TextBlock (innerTextRaw), history (collapsedChars)
- Surfaces historyTextChars separately so history's no-collapse
  rejections still contribute a denominator

tracker.ts:
- TrackEvent gains bucket_chars?: Partial<Record<BucketName, number>>
  and history_text_chars?: number
- toTrackEvent copies them when populated, drops empty bucket maps
  so happy-path events stay small

tests/tracker.test.ts:
- Asserts snake_case field names and nested shape land correctly
- Asserts absent buckets and pass-through events stay clean

No gate logic changed. CHARS_PER_TOKEN / SLAB_CHARS_PER_TOKEN /
HISTORY_CHARS_PER_TOKEN still drive isCompressionProfitable. Phase 2
will fit per-bucket slopes from the captured events and wire a
bucket-aware override.

docs/ADAPTIVE_CPT_PLAN.md: full plan + empirical basis (slab-only
regression on 87 events recovered cpt≈1.50 vs the baked 2.5
constant — 40% on non-slab compressions ship today).

Verified: tsc --noEmit clean; 290/290 tests pass (+2 new);
build emits dist/node.js.
2026-05-20 21:21:34 -04:00

289 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { toTrackEvent, JsonLogTracker, noopTracker, type TrackEvent } from '../src/core/tracker.js';
import type { ProxyEvent } from '../src/core/proxy.js';
describe('toTrackEvent', () => {
it('flattens ProxyEvent + TransformInfo + Usage into a single record', () => {
const ev: ProxyEvent = {
method: 'POST',
path: '/v1/messages',
status: 200,
durationMs: 1234,
firstByteMs: 200,
info: {
compressed: true,
origChars: 16000,
imageCount: 1,
imageBytes: 2103,
staticChars: 14000,
dynamicChars: 500,
dynamicBlockCount: 2,
systemSha8: 'a1b2c3d4',
claudeMdSha8: 'cafebabe',
firstUserSha8: 'deadbeef',
unknownStaticTags: ['recent_files'],
env: {
cwd: '/Users/me/code/pp',
isGitRepo: true,
gitBranch: 'main',
platform: 'darwin',
osVersion: 'Darwin 25.0.0',
today: '2026-05-18',
},
},
usage: {
input_tokens: 42,
output_tokens: 7,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 100,
},
};
const out = toTrackEvent(ev);
// Spot-check every category of field made it across with the right
// snake_case names.
expect(out.method).toBe('POST');
expect(out.path).toBe('/v1/messages');
expect(out.status).toBe(200);
expect(out.duration_ms).toBe(1234);
expect(out.first_byte_ms).toBe(200);
expect(out.compressed).toBe(true);
expect(out.orig_chars).toBe(16000);
expect(out.static_chars).toBe(14000);
expect(out.dynamic_chars).toBe(500);
expect(out.dynamic_block_count).toBe(2);
expect(out.system_sha8).toBe('a1b2c3d4');
expect(out.claude_md_sha8).toBe('cafebabe');
expect(out.first_user_sha8).toBe('deadbeef');
expect(out.unknown_static_tags).toEqual(['recent_files']);
expect(out.cwd).toBe('/Users/me/code/pp');
expect(out.git_branch).toBe('main');
expect(out.is_git_repo).toBe(true);
expect(out.input_tokens).toBe(42);
expect(out.cache_read_tokens).toBe(100);
expect(out.cache_create_tokens).toBe(0);
// ts is ISO8601
expect(out.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('captures the nested cache_creation split and server_tool_use counters', () => {
// Anthropic's `usage` block carries some fields inline and others nested
// under `cache_creation` / `server_tool_use`. The flat 4-field view we
// historically copied silently dropped 3 real billing dimensions:
// - output_tokens (rate-multiplier ×5 — see dashboard math)
// - cache_creation.ephemeral_5m_input_tokens (1.25× rate)
// - cache_creation.ephemeral_1h_input_tokens (2× rate — meaningful $)
// - server_tool_use.web_search_requests (billed per request, not token)
// Regression guard for the May-2026 audit that surfaced the gap.
const out = toTrackEvent({
method: 'POST',
path: '/v1/messages',
status: 200,
durationMs: 100,
usage: {
input_tokens: 10,
output_tokens: 250,
cache_creation_input_tokens: 1000,
cache_read_input_tokens: 50,
cache_creation: {
ephemeral_5m_input_tokens: 900,
ephemeral_1h_input_tokens: 100,
},
server_tool_use: {
web_search_requests: 3,
},
},
});
expect(out.output_tokens).toBe(250);
expect(out.cache_create_tokens).toBe(1000);
expect(out.cache_create_5m_tokens).toBe(900);
expect(out.cache_create_1h_tokens).toBe(100);
expect(out.web_search_requests).toBe(3);
});
it('omits the nested usage fields when Anthropic does not return them', () => {
// Older API versions and non-cache requests return only the flat
// fields. The optional copies must stay undefined, not zero — zero is
// a real value that means "we measured it and it was zero".
const out = toTrackEvent({
method: 'POST',
path: '/v1/messages',
status: 200,
durationMs: 100,
usage: {
input_tokens: 10,
output_tokens: 5,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
});
expect(out.cache_creation_5m_tokens).toBeUndefined();
expect(out.cache_creation_1h_tokens).toBeUndefined();
expect(out.web_search_requests).toBeUndefined();
});
it('surfaces bucket_chars and history_text_chars when present', () => {
// Phase 1 of Task #18: per-block char attribution by content shape.
// The rolling cpt regression in tests/proxy-usage.test.ts and the
// dashboard read this back, so getting the snake_case names + nested
// shape right matters. Absent buckets must be omitted entirely so the
// happy-path events stay lean.
const out = toTrackEvent({
method: 'POST',
path: '/v1/messages',
status: 200,
durationMs: 50,
info: {
compressed: true,
origChars: 30000,
bucketChars: {
static_slab: 27000,
reminder: 1500,
tool_result_log: 800,
tool_result_prose: 400,
history: 300,
},
historyTextChars: 300,
},
});
expect(out.bucket_chars).toEqual({
static_slab: 27000,
reminder: 1500,
tool_result_log: 800,
tool_result_prose: 400,
history: 300,
});
expect(out.history_text_chars).toBe(300);
});
it('omits bucket_chars when no gates fired and the bucket map is empty', () => {
// Pass-through requests (compress=false, parse errors) never call
// bumpBucket. `info.bucketChars` either stays undefined or — if
// something allocated the sub-object without writing — should still
// not show up in the persisted event. Otherwise consumers can't tell
// "we measured zero buckets" from "we never ran the gate".
const noBuckets = toTrackEvent({
method: 'POST',
path: '/v1/messages',
status: 200,
durationMs: 10,
info: { compressed: false, reason: 'compress=false', origChars: 0 },
});
expect(noBuckets.bucket_chars).toBeUndefined();
expect(noBuckets.history_text_chars).toBeUndefined();
const emptyBucketMap = toTrackEvent({
method: 'POST',
path: '/v1/messages',
status: 200,
durationMs: 10,
info: { compressed: true, origChars: 100, bucketChars: {} },
});
expect(emptyBucketMap.bucket_chars).toBeUndefined();
});
it('handles a minimal ProxyEvent (no info, no usage) without throwing', () => {
const out = toTrackEvent({
method: 'GET',
path: '/health',
status: 200,
durationMs: 4,
});
expect(out.method).toBe('GET');
expect(out.compressed).toBeUndefined();
expect(out.cwd).toBeUndefined();
expect(out.input_tokens).toBeUndefined();
});
});
describe('JsonLogTracker', () => {
it('emits one JSON line per event to the sink', () => {
const lines: string[] = [];
const t = new JsonLogTracker((s) => lines.push(s));
t.emit({ ts: '2026-05-18T00:00:00Z', method: 'POST', path: '/v1/messages', status: 200, duration_ms: 1 } as TrackEvent);
t.emit({ ts: '2026-05-18T00:00:01Z', method: 'POST', path: '/v1/messages', status: 200, duration_ms: 2 } as TrackEvent);
expect(lines).toHaveLength(2);
const parsed = lines.map((l) => JSON.parse(l));
expect(parsed[0].duration_ms).toBe(1);
expect(parsed[1].duration_ms).toBe(2);
});
it('swallows sink errors — tracker must never break a request', () => {
const t = new JsonLogTracker(() => {
throw new Error('disk full');
});
expect(() =>
t.emit({ ts: 'x', method: 'POST', path: '/v1/messages', status: 200, duration_ms: 1 } as TrackEvent),
).not.toThrow();
});
});
describe('noopTracker', () => {
it('discards events silently', () => {
expect(() =>
noopTracker.emit({ ts: 'x', method: 'POST', path: '/v1/messages', status: 200, duration_ms: 1 } as TrackEvent),
).not.toThrow();
});
});
describe('toTrackEvent body-sample mapping', () => {
const baseEv = {
method: 'POST',
path: '/v1/messages',
status: 400,
durationMs: 100,
};
it('inlines small gzipped bodies as req_body_sample_b64', () => {
const small = new Uint8Array(64).fill(0x1f);
const out = toTrackEvent({
...baseEv,
reqBodySha8: 'deadbeef',
reqBodyGz: small,
} as ProxyEvent);
expect(out.req_body_sha8).toBe('deadbeef');
expect(out.req_body_sample_b64).toBeDefined();
expect(out.req_body_sample_b64!.length).toBeLessThanOrEqual(128);
expect(out.req_body_sample_path).toBeUndefined();
});
it('drops oversized gzipped bodies that lack a sidecar path', () => {
// 40 KiB of gz bytes → ~53 KiB base64 → exceeds the 32 KiB cap, and we
// didn't pre-write a sidecar → must silently drop the inline body
// (Workers path). req_body_sha8 still lands.
const big = new Uint8Array(40 * 1024).fill(0x42);
const out = toTrackEvent({
...baseEv,
reqBodySha8: 'cafef00d',
reqBodyGz: big,
} as ProxyEvent);
expect(out.req_body_sha8).toBe('cafef00d');
expect(out.req_body_sample_b64).toBeUndefined();
expect(out.req_body_sample_path).toBeUndefined();
});
it('prefers reqBodySamplePath over inlining when both are set', () => {
const someGz = new Uint8Array(64).fill(0x1f);
const out = toTrackEvent({
...baseEv,
reqBodySha8: 'feedface',
reqBodyGz: someGz,
reqBodySamplePath: '/tmp/4xx-bodies/x.json.gz',
} as ProxyEvent);
// Sidecar path wins; we don't double-encode inline.
expect(out.req_body_sample_path).toBe('/tmp/4xx-bodies/x.json.gz');
expect(out.req_body_sample_b64).toBeUndefined();
});
it('sets req_body_sha8 on 2xx events too (correlation across statuses)', () => {
const out = toTrackEvent({
...baseEv,
status: 200,
reqBodySha8: '01234567',
} as ProxyEvent);
expect(out.req_body_sha8).toBe('01234567');
// No body sample fields for 2xx.
expect(out.req_body_sample_b64).toBeUndefined();
expect(out.req_body_sample_path).toBeUndefined();
});
});