mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
3109a3b5a4
Reads ~/.pixelpipe/events.jsonl (or --file / PIXELPIPE_LOG) and prints
a one-screen summary of how the proxy is doing:
- request counts by status bucket (2xx/4xx/5xx)
- compression rate (compressed vs passthrough)
- latency p50/p95/p99 (duration + first-byte separately)
- aggregate Anthropic token usage (input/output/cache_create/cache_read)
- cache hit rate computed two ways (by tokens, by events)
- top skip reasons (so we see WHY a request wasn't compressed)
- top cwds with per-project compression ratio
- system_sha8 distribution → 'reuse rate' = how often the cached
payload is being re-sent. High reuse + low cache_read means
something is breaking cache_control.
- unknown_static_tags warning section if Claude Code shipped a new
dynamic tag that we're silently baking into the image.
--json mode dumps the raw Summary as JSON for downstream pipelines.
Streaming reader so a 100 MB log file doesn't OOM. Pure aggregator
(fold/newSummary/renderTextReport) is unit-tested; only the file IO
shell is untested.
Dispatched from src/node.ts BEFORE parseCli so its flags don't collide
with the proxy's flags. The CLI shim in bin/cli.js stays unchanged.
Tests: +6 (status bucketing, compressed/passthrough split, token agg,
cwd & sha histogram, unknown-tag collection, text report rendering).
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { newSummary, fold, renderTextReport } from '../src/stats.js';
|
|
import type { TrackEvent } from '../src/core/tracker.js';
|
|
|
|
function ev(partial: Partial<TrackEvent>): TrackEvent {
|
|
return {
|
|
ts: '2026-05-18T00:00:00Z',
|
|
method: 'POST',
|
|
path: '/v1/messages',
|
|
status: 200,
|
|
duration_ms: 100,
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
describe('stats aggregator', () => {
|
|
it('counts status buckets', () => {
|
|
const s = newSummary();
|
|
fold(s, ev({ status: 200 }));
|
|
fold(s, ev({ status: 201 }));
|
|
fold(s, ev({ status: 404 }));
|
|
fold(s, ev({ status: 503 }));
|
|
fold(s, ev({ status: 500 }));
|
|
expect(s.total).toBe(5);
|
|
expect(s.ok2xx).toBe(2);
|
|
expect(s.err4xx).toBe(1);
|
|
expect(s.err5xx).toBe(2);
|
|
});
|
|
|
|
it('separates compressed vs passthrough and collects skip reasons', () => {
|
|
const s = newSummary();
|
|
fold(s, ev({ compressed: true, orig_chars: 1000, image_bytes: 200 }));
|
|
fold(s, ev({ compressed: true, orig_chars: 2000, image_bytes: 300 }));
|
|
fold(s, ev({ compressed: false, reason: 'below_min_chars (50 < 2000)' }));
|
|
fold(s, ev({ compressed: false, reason: 'below_min_chars (60 < 2000)' }));
|
|
fold(s, ev({ compressed: false, reason: 'compress=false' }));
|
|
expect(s.compressed).toBe(2);
|
|
expect(s.passthrough).toBe(3);
|
|
expect(s.origCharsTotal).toBe(3000);
|
|
expect(s.imageBytesTotal).toBe(500);
|
|
// Reasons keep their exact string form (parenthetical char counts and
|
|
// all) — useful for spotting outliers without collapsing detail.
|
|
expect(s.skipReasons.size).toBe(3);
|
|
expect(s.skipReasons.get('below_min_chars (50 < 2000)')).toBe(1);
|
|
expect(s.skipReasons.get('below_min_chars (60 < 2000)')).toBe(1);
|
|
expect(s.skipReasons.get('compress=false')).toBe(1);
|
|
});
|
|
|
|
it('aggregates Anthropic token usage and computes cache hit metrics', () => {
|
|
const s = newSummary();
|
|
fold(
|
|
s,
|
|
ev({
|
|
input_tokens: 100,
|
|
output_tokens: 10,
|
|
cache_read_tokens: 0,
|
|
cache_create_tokens: 5000,
|
|
}),
|
|
);
|
|
fold(
|
|
s,
|
|
ev({
|
|
input_tokens: 50,
|
|
output_tokens: 5,
|
|
cache_read_tokens: 5000,
|
|
cache_create_tokens: 0,
|
|
}),
|
|
);
|
|
fold(
|
|
s,
|
|
ev({
|
|
input_tokens: 60,
|
|
output_tokens: 6,
|
|
cache_read_tokens: 5000,
|
|
cache_create_tokens: 0,
|
|
}),
|
|
);
|
|
// 3 events all carried usage; 2 had cache_read > 0.
|
|
expect(s.eventsWithUsage).toBe(3);
|
|
expect(s.cacheHitEvents).toBe(2);
|
|
expect(s.inputTokensTotal).toBe(210);
|
|
expect(s.outputTokensTotal).toBe(21);
|
|
expect(s.cacheReadTokensTotal).toBe(10000);
|
|
expect(s.cacheCreateTokensTotal).toBe(5000);
|
|
});
|
|
|
|
it('buckets by cwd and tracks system_sha8 reuse', () => {
|
|
const s = newSummary();
|
|
fold(s, ev({ cwd: '/a', system_sha8: 'aaa', orig_chars: 100, image_bytes: 20 }));
|
|
fold(s, ev({ cwd: '/a', system_sha8: 'aaa', orig_chars: 100, image_bytes: 20 }));
|
|
fold(s, ev({ cwd: '/b', system_sha8: 'bbb', orig_chars: 200, image_bytes: 40 }));
|
|
expect(s.byCwd.size).toBe(2);
|
|
expect(s.byCwd.get('/a')!.count).toBe(2);
|
|
expect(s.byCwd.get('/a')!.origChars).toBe(200);
|
|
expect(s.systemShaHist.get('aaa')).toBe(2);
|
|
expect(s.systemShaHist.get('bbb')).toBe(1);
|
|
});
|
|
|
|
it('collects unknown_static_tags across events', () => {
|
|
const s = newSummary();
|
|
fold(s, ev({ unknown_static_tags: ['recent_files', 'todo_list'] }));
|
|
fold(s, ev({ unknown_static_tags: ['recent_files'] }));
|
|
fold(s, ev({}));
|
|
expect(s.unknownTags.get('recent_files')).toBe(2);
|
|
expect(s.unknownTags.get('todo_list')).toBe(1);
|
|
});
|
|
|
|
it('renders a non-empty text report for a populated summary', () => {
|
|
const s = newSummary();
|
|
for (let i = 0; i < 100; i++) {
|
|
fold(
|
|
s,
|
|
ev({
|
|
compressed: true,
|
|
orig_chars: 5000,
|
|
image_bytes: 1000,
|
|
input_tokens: 50,
|
|
cache_read_tokens: i % 2 === 0 ? 4000 : 0,
|
|
cache_create_tokens: i % 2 === 0 ? 0 : 4000,
|
|
duration_ms: 100 + i,
|
|
first_byte_ms: 30 + i,
|
|
cwd: '/Users/x/code/pp',
|
|
system_sha8: 'stable',
|
|
}),
|
|
);
|
|
}
|
|
const out = renderTextReport(s);
|
|
expect(out).toContain('pixelpipe stats');
|
|
expect(out).toContain('compressed');
|
|
expect(out).toContain('cache hit rate');
|
|
expect(out).toContain('/Users/x/code/pp');
|
|
expect(out).toContain('stable');
|
|
// 50% cache hit rate by event.
|
|
expect(out).toMatch(/cache hit rate \(by events\):\s+50.0%/);
|
|
});
|
|
});
|