Repository navigation
feat(cli): token analytics — session audit + sessions inventory - #1
Conversation
Two pnpm commands that answer "where do billed tokens go" and "what does Claude Code waste" with exact usage numbers straight from session JSONL (input / cache_read / cache_write / output per turn and round): - pnpm analyze:session — per-turn ledger, waste findings (duplicates, failed calls, oversized outputs, context spikes, dead caching, thinking-heavy turns), slow-subagent table (>5 min by default), optional cost estimate via a built-in claude pricing table. - pnpm analyze:sessions — inventory of all sessions: duration, models, token totals, billing scheme (anthropic-style vs router-style vs mixed), sortable, filterable. Deliberately local-only: glm/router billing specifics are not meant for upstream. priceFamily() is a temporary stopgap while the upstream short-model-id parser fix is unmerged. Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Ревью диффа 16cc3c8..ff8a96c (5 файлов, +1222). Запрашиваю изменения: одна реальная бага в точности findings (normalizeCallKey схлопывает вложенные объекты — ложные duplicate_call), плюс robustness инвентаря (один нечитаемый файл убивает весь скан).
Что проверено и в порядке:
- Логика ledger корректна: дедуп по requestId совпадает с deduplicateByRequestId (последняя запись = финальные счётчики), turn-границы через isParsedUserChunkMessage верны (isMeta/teammate/system-stdout не создают ходов), sidechain и исключены из обеих CLI — согласованно с app-пайплайном.
- Cost не задваивается (считается после дедупа), прайс-таблица соответствует anthropic-тарифам (cache_write = 5m-TTL ставка), отсутствие цены для неизвестных моделей корректно даёт n/a.
- Сигнатуры биллинга согласованы между detectBillingScheme и scanSessionFile (одинаковый else-if); эвристика mixed задокументирована в описании PR.
- Собственный readline-проход в инвентаре оправдан (parseJsonlFile материализует ParsedMessage); импорты все существуют, сигнатуры SubagentResolver/ProjectScanner совпадают, subagent-resolution обёрнут в try/catch с graceful-деградацией, knip-entries добавлены правильно.
- Тесты покрывают ключевую семантику (turn-границы, дедуп, ключи дублей, rejection vs fail, billing).
Находки — в инлайн-комментариях; все три фикса мелкие.
| let i = 0; | ||
| while (i < argv.length) { | ||
| const a = argv[i]; | ||
| const value = argv[i + 1] ?? ''; |
There was a problem hiding this comment.
Minor: значение флага берётся безусловно из следующего argv — --rounds --json съедает --json (parseInt("--json") → NaN → 20, i += 2 пропускает --json), вместо JSON человек получит текстовый отчёт. То же для --min-severity. Достаточно проверки value.startsWith("--").
- sessionInventory: importing analyzeSession no longer triggers its CLI
main (entry detection via import.meta.url instead of the VITEST env
guard) — analyze:sessions used to always exit 1 with a stray usage
line on stderr from the phantom main of the imported module
- normalizeCallKey: sort own top-level keys without a replacer array —
the array form recurses and flattened nested objects to {}, so
TodoWrite/AskUserQuestion/MCP calls differing only in nesting shared
one key (false duplicate_call findings)
- sessionInventory: isolate per-file scan errors in mapWithConcurrency —
one unreadable file no longer aborts the whole inventory; skipped
files are reported on stderr
- sessionInventory: decode project paths via decodePath + os.homedir()
instead of the hardcoded -Users-axisrow-Projects- prefix
- both CLIs: a flag value is taken from the next token only when it is
not itself a flag (--rounds --json keeps --json)
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Все находки адресованы в 4bdbdf4 (плюс один фикс поверх, найденный при живой верификации):
Дополнительно, найдено при верификации ревью и тоже зафикшено в 4bdbdf4: гард Верификация: |
axisrow
left a comment
There was a problem hiding this comment.
Ревью PR #1 — feat(cli): token analytics
Резюме. Крепкий, сфокусированный инструмент: правильное переиспользование проверенных примитивов мейн-процесса (deduplicateByRequestId, isParsedUserChunkMessage, SubagentResolver, токен-форматтеры), оправданный собственный readline-проход в инвентаре (parseJsonlFile материализует ParsedMessage и раздувается на 1200+ файлах), аккуратные ponytail-пометки на сознательных стопгапах. Семантика ledger корректна: last-wins дедуп по requestId совпадает между streaming- и parse-путями, turn-границы через isParsedUserChunkMessage (isMeta/teammate не создают ходов), sidechain и <synthetic> исключены, cost считается после дедупа, пустые сессии дают durationMs: 0 без NaN. Тесты бьют в ключевую семантику. Конвенции соблюдены: path aliases, pnpm-скрипты, knip-entries, test/main/cli/.
Замечания — по убыванию серьёзности; статусы указаны после фикса 4bdbdf4, запушенного в эту ветку в ходе ревью (merge не выполнялся).
1. HIGH — фантомный main() при импорте: каждый запуск analyze:sessions завершался exit 1
src/cli/sessionInventory.ts:15 (импорт) + src/cli/analyzeSession.ts:666 (гард)
Гард !process.env.VITEST не срабатывает под tsx: импорт ./analyzeSession из sessionInventory исполнял его top-level main(). Тот не находил свою сессию в argv, печатал usage на stderr и ставил process.exitCode = 1 — любой реальный прогон pnpm analyze:sessions падал с exit 1 при внешне успешном отчёте (ломает скриптинг, глазами не видно). Воспроизведено на живых данных до фикса: exit 1 + чужая usage-строка перед таблицей. Исправлено стандартной проверкой точки входа import.meta.url === pathToFileURL(process.argv[1]).href в обоих файлах (покрывает и vitest-импорт). — fixed in 4bdbdf4
2. MEDIUM — ложные duplicate_call из-за схлопывания вложенных объектов
src/cli/analyzeSession.ts:276
JSON.stringify(input, keys[]) применяет replacer-массив рекурсивно — вложенные объекты flattening'ятся в {}: TodoWrite/AskUserQuestion/MCP-вызовы с разными вложенными данными получали один ключ дубликата с завышенным tokensWasted. Исправлено: Object.fromEntries по отсортированным собственным ключам + регрессионный тест. — fixed in 4bdbdf4
3. MEDIUM — один нечитаемый файл ронял весь инвентарь
src/cli/sessionInventory.ts:180
mapWithConcurrency без изоляции ошибок: EACCES/EBUSY на любом из 1200+ файлов или исчезнувший каталог режектили Promise.all и весь скан. Исправлено: try/catch вокруг fn(item) с push(null) и stderr-замечанием; null-фильтры внизу уже существовали. — fixed in 4bdbdf4
4. MEDIUM (открытый вопрос, не блокер) — sidechain-инконсистентность между двумя CLI
src/cli/sessionInventory.ts:99
scanSessionFile не фильтрует isSidechain, а buildLedger — фильтрует: для сессий с субагентами суммы инвентаря выше ledger analyze:session, модели и сигнатура биллинга могут расходиться (cache_write субагента даёт sawWrite). Если это сознательно («всё, что биллинговано в сессию») — достаточно одной строки комментария; иначе добавить фильтр !e.isSidechain.
5. LOW — duplicate_call завышает tokensWasted
src/cli/analyzeSession.ts:330-346
При N повторах суммируются все N результатов, хотя re-read — только N-1. Либо вычесть токены первого результата, либо переформулировать summary («~X tok across N calls»).
6. LOW — UX ошибок --project в analyze:session
src/cli/analyzeSession.ts:619-629
--project X без --last или с несуществующей папкой даёт generic usage/exit 1, хотя у sessionInventory есть точное «project dir not found» (sessionInventory.ts:234) — стоит переиспользовать. Там же statSync в pickNewestSessionFile (analyzeSession.ts:468) может бросить при гонке удаления — уйдёт в общий catch со стеком.
Нити (не блокеры)
- Смешанные сессии (claude+glm):
costUsdсуммирует только прайсовые раунды, но печатается как итог сессии — можно помечать «(partial)» приbilling === 'mixed'. ~//ao/...в колонке project после фикса 3 — артефактdecodePathдля точек в путях; читаемо, починить можно отдельно вpathDecoder.
Верификация
pnpm typecheck— чисто (до и после фиксов)pnpm test— 736 passed / 55 файлов, включая 14 CLI-тестов (+2 регрессионных на фиксы 1–2)- Живой прогон
analyze:sessionsпо 1235 сессиям — exit 0 после фиксов (до — exit 1);analyze:session --help— корректная одиночная usage-строка
- sessionInventory: exclude sidechain (subagent) entries from token totals, models and billing so inventory matches buildLedger — duration and message count still span the whole file - analyzeSession: duplicate_call tokensWasted counts only re-reads (repeat results), not the first legitimate result - analyzeSession: totals carry costPartial — printed as "(partial — unpriced models excluded)" when a session mixes priced/unpriced models - analyzeSession: --project without --last, missing project dirs and dirs without sessions get precise errors instead of generic usage - analyzeSession: pickNewestSessionFile survives files vanishing between readdir and stat - mapWithConcurrency exported with a per-item failure isolation test Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Доделаны оставшиеся замечания из ревью —
Верификация: |
axisrow
left a comment
There was a problem hiding this comment.
Переревью диффа ff8a96c..fbb3872 (4bdbdf4 + fbb3872). Апрув — все находки первого ревью закрыты, новых проблем не нашёл.
По пунктам:
- normalizeCallKey — Object.fromEntries с сортировкой ключей верхнего уровня, вложенные объекты сохраняются. Тест на два разных TodoWrite добавлен.
- mapWithConcurrency — try/catch с push(null), оба вызова в collect фильтруют null; тест изоляции отказов есть; skipped-строки идут в stderr, JSON-вывод не портится.
- Хардкод юзернейма заменён на shortenHome(decodePath(projectId)). decodePath — канонический best-effort декод кодбейза (ProjectPathResolver использует тот же), поведение консистентно с приложением; тире-неоднозначность — известное общее ограничение pathDecoder, не новая регрессия.
- takeFlagValue — флаг без значения больше не съедает следующий флаг; значения с одним ведущим тире (--project -Users-...) по-прежнему работают. Тест есть.
Бонус-фиксы fbb3872 проверил: sidechain-фильтр в scanSessionFile согласован с buildLedger (таймстампы файла осознанно включают sidechain — прокомментировано и покрыто тестом), duplicate_call tokensWasted теперь только re-readы (семантика точнее), costPartial-маркер корректен (показывается только при costUsd > 0), явные ошибки --project без --last / dir not found / пустая папка — улучшение UX, statSync-гонка в pickNewestSessionFile закрыта, замена VITEST-гарда на import.meta.url-проверку корректна и нужна (sessionInventory импортирует хелперы из analyzeSession).
Тесты на каждую находку добавлены (16 CLI-тестов), typecheck/lint по докладу воркера зелёные. Готово к мержу.
axisrow
left a comment
There was a problem hiding this comment.
Ревью для run e6ca9164: head fbb3872 не изменился с момента моего переревью (review 5260930339). Дифф ff8a96c..fbb3872 проверен полностью, вердикт тот же — апрув.
Итог по коммитам:
- 4bdbdf4 закрывает все 4 находки первого ревью: normalizeCallKey на Object.fromEntries (вложенные объекты сохранены, тест на TodoWrite), try/catch+push(null) в mapWithConcurrency с фильтрацией null в collect и тестом изоляции отказов, decodePath+shortenHome вместо хардкода юзернейма, takeFlagValue-гард против проглатывания следующего флага (тест есть, значения вида --project -Users-... работают).
- fbb3872: sidechain-фильтр в scanSessionFile согласован с buildLedger (таймстампы файла включают sidechain осознанно), duplicate_call tokensWasted = только re-readы, costPartial-маркер корректен, явные ошибки --project, statSync-гонка закрыта, import.meta.url-гард вместо VITEST.
Новых проблем не найдено; тесты на каждую находку добавлены (16 CLI), typecheck/lint зелёные. Готово к мержу.
Что это
Две CLI-команды, которые регулярно отвечают на два вопроса точными цифрами из JSONL сессий (без пересборки приложения):
pnpm analyze:session <file.jsonl>— аудит сессии: ledger по ходам и раундам (input / cache_read / cache_write / output), находки мусора (дубли вызовов, фейлы, простыни вывода, скачки контекста, мёртвый кэш, thinking-heavy ходы), таблица субагентов с флагом!SLOW >5min, оценка стоимости по встроенной claude-прайс-таблице.pnpm analyze:sessions— реестр всех сессий: длительность, модели, суммы токенов, схема биллинга, сортировки и фильтры (--min-minutes 120= «какие сессии длились 2ч+»).Схемы биллинга различаются по сигнатуре cache-счётчиков:
cache_write > 0→anthropic-style,cw=0 + cache_read>0→router-style, обе сигнатуры →mixed.Заметки для ревью
priceFamily()— временный стопгап для коротких id (claude-sonnet-5), удаляется когда fix(parser): parse short model ids without date suffix matt1398/claude-devtools#235 смержится.mixed— эвристика: у Anthropic бывают естественные раунды сcache_write=0(в кэше уже всё есть), они дают ложную половинку router-сигнатуры. Токены при этом точные.parseJsonlFile: он материализуетParsedMessageи раздувается на 1200+ файлах, включая 66MB).Validation checklist
pnpm typecheckpnpm lint(0 ошибок)pnpm test(734 passed, из них 9 новых на CLI)billing: anthropic-style,est. cost: $2.39; текущая glm-сессия →router-style, costn/a; реестр 1236 сессий с колонкой billing🤖 Generated with Claude Code