Skip to content

feat(cli): token analytics — session audit + sessions inventory - #1

Merged
axisrow merged 3 commits into
mainfrom
local/extras
Sep 20, 2026
Merged

axisrow merged 3 commits into
mainfrom
local/extras

Conversation

@axisrow

@axisrow axisrow commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Что это

Две 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.

Заметки для ревью

  • Осознанно НЕ для апстрима (matt1398): glm/роутер-биллинг — локальные внутренности; туда идёт только parser-fix (#235).
  • priceFamily() — временный стопгап для коротких id (claude-sonnet-5), удаляется когда fix(parser): parse short model ids without date suffix matt1398/claude-devtools#235 смержится.
  • mixed — эвристика: у Anthropic бывают естественные раунды с cache_write=0 (в кэше уже всё есть), они дают ложную половинку router-сигнатуры. Токены при этом точные.
  • Стример инвентаря — свой readline-проход сознательно (не parseJsonlFile: он материализует ParsedMessage и раздувается на 1200+ файлах, включая 66MB).

Validation checklist

  • pnpm typecheck
  • pnpm lint (0 ошибок)
  • pnpm test (734 passed, из них 9 новых на CLI)
  • Живые прогоны: августовская anthropic-сессия → billing: anthropic-style, est. cost: $2.39; текущая glm-сессия → router-style, cost n/a; реестр 1236 сессий с колонкой billing

🤖 Generated with Claude Code

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 axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревью диффа 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).

Находки — в инлайн-комментариях; все три фикса мелкие.

Comment thread src/cli/analyzeSession.ts
Comment thread src/cli/sessionInventory.ts
Comment thread src/cli/sessionInventory.ts Outdated
Comment thread src/cli/analyzeSession.ts Outdated
let i = 0;
while (i < argv.length) {
const a = argv[i];
const value = argv[i + 1] ?? '';

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@axisrow

axisrow commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Все находки адресованы в 4bdbdf4 (плюс один фикс поверх, найденный при живой верификации):

  1. normalizeCallKey (analyzeSession.ts:276) — replacer-массив заменён на Object.fromEntries по отсортированным собственным ключам; вложенные объекты сериализуются полностью. Регрессионный тест: два TodoWrite с разными todos больше не коллайдят.
  2. mapWithConcurrency (sessionInventory.ts:180) — try/catch вокруг fn(item) с push(null) и stderr-замечанием; fileGroups фильтруется перед flat(). Один нечитаемый файл больше не роняет скан.
  3. Хардкод юзернейма (sessionInventory.ts:260) — decodePath(e.projectId) + аббревиатура ~ через os.homedir().
  4. Парсер флагов (analyzeSession.ts:425) — takeFlagValue(): значение берётся из следующего токена, только если тот сам не флаг; --rounds --json сохраняет --json (в обоих CLI). Тест добавлен.

Дополнительно, найдено при верификации ревью и тоже зафикшено в 4bdbdf4: гард !process.env.VITEST не срабатывал под tsx — импорт ./analyzeSession из sessionInventory исполнял его top-level main(), из-за чего каждый реальный прогон pnpm analyze:sessions печатал чужой usage на stderr и всегда завершался exit 1 (воспроизведено на живых данных). Заменено на стандартную проверку точки входа import.meta.url === pathToFileURL(process.argv[1]).href в обоих файлах.

Верификация: pnpm typecheck чисто; pnpm test — 736 passed / 55 файлов (+2 регрессионных); живой прогон analyze:sessions по 1235 сессиям — exit 0 после фикса; analyze:session --help — без фантомного usage.

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревью 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

axisrow commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Доделаны оставшиеся замечания из ревью — fbb3872:

  • sidechain-фильтр (scanSessionFile): субагентные записи исключены из totals/models/billing — инвентарь теперь согласован с buildLedger; duration и message count по-прежнему покрывают весь файл. Тест: sidechain-запись с cache_creation не меняет токены и router-style.
  • duplicate_call tokensWasted: считается только re-read (повторы), первый легитимный результат вычтен. Тест обновлён.
  • partial cost: totals.costPartial — при смешении прайс/непрайс моделей отчёт печатает est. cost: $X (partial — unpriced models excluded). Тест на mix claude+glm.
  • UX --project (analyze:session): --project требует --last, «project dir not found: …» (в стиле sessionInventory), «no .jsonl sessions found in …» — вместо generic usage; pickNewestSessionFile переживает файл, исчезнувший между readdir и stat.
  • mapWithConcurrency экспортирован + тест изоляции отказов (упавший элемент → null, остальные резолвятся).

Верификация: pnpm typecheck ✓ · pnpm lint — 0 ошибок (5 старых предупреждений вне PR) · pnpm test — 738 passed / 55 файлов (16 CLI-тестов) · live: analyze:sessions по 1236 сессиям — exit 0; --project nonexistent --last → «project dir not found»; --project X без --last → «--project requires --last».

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Переревью диффа ff8a96c..fbb3872 (4bdbdf4 + fbb3872). Апрув — все находки первого ревью закрыты, новых проблем не нашёл.

По пунктам:

  1. normalizeCallKey — Object.fromEntries с сортировкой ключей верхнего уровня, вложенные объекты сохраняются. Тест на два разных TodoWrite добавлен.
  2. mapWithConcurrency — try/catch с push(null), оба вызова в collect фильтруют null; тест изоляции отказов есть; skipped-строки идут в stderr, JSON-вывод не портится.
  3. Хардкод юзернейма заменён на shortenHome(decodePath(projectId)). decodePath — канонический best-effort декод кодбейза (ProjectPathResolver использует тот же), поведение консистентно с приложением; тире-неоднозначность — известное общее ограничение pathDecoder, не новая регрессия.
  4. 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 axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревью для 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 зелёные. Готово к мержу.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant