0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🔍 52日間・2340行、コスト蚘録がぜんぶれロだったStop hookの眠

0
Posted at

月10䞇の倧孊生から半幎でここたで来られたのは、Claudeに環境ごず自埋させたからです──そしおその環境が、52日間・2340行にわたっおコストをすべおれロず蚘録しながら、䞀床も文句を蚀わずに動き続けおいたした。

なぜこの仕組みが効くのか

Claude Codeを䜿い始めた倚くの人は、最初は「䟿利なチャットAI」ずしお䜿いたす。でも、月商が䞀定額を超えるず考え方が倉わりたす。AIに「指瀺しお動かす」のではなく、「環境ごず自埋させる」ずいう発想ぞの転換です。

違いを具䜓的に蚀うず、こうです。前者は毎回自分がプロンプトを打っお結果をもらいたす。埌者は、自分が寝おいる間もhookが走り、スクリプトが実行され、ログが積たれおいたす。わたしの堎合、launchdで定時実行されるゞョブが十数本あり、Claude Code の Stop hook が毎セッション末に走りたす。朝起きるずDesktopに前日のブリヌフが届いおいお、どのセッションがどれだけトヌクンを消費したかが ~/.claude/metrics/costs.jsonl に蚘録されおいる──それが理想圢でした。

なぜコスト远跡が必芁かずいうず、Claude Code のMAX契玄は月定額ですが「䜿いすぎるず翌月のキャパが実質的に詰たる」ずいう感芚的な䞊限がありたす。どのセッションに、どのモデルで、どれだけ消費したかを可芖化しおおかないず、コスト感芚がない状態で自埋゚ヌゞェントを走らせ続けるこずになりたす。自埋環境は䟿利なほどコストを無自芚に食いたす。だから蚈枬が先決なのです。

Stop hook はこの蚈枬を担う仕組みです。Claude Code は各セッションが終了するずきナヌザヌが /exit したずき、タむムアりトしたずきに、settings.json の Stop セクションに登録されたコマンドを実行したす。そこにコスト集蚈スクリプトを眮けば、「セッション終了 = 自動で蚘録」ずいうパむプラむンが成立したす。人間がコストをExcelに手入力するようなこずは䞍芁になりたす。

「動いおいる」ず「正しく動いおいる」は違う──この感芚、゚ンゞニアならわかるず思いたす。ログが出続けおいるのに䞭身が党郚れロずいうのは、゚ラヌより始末が悪いです。゚ラヌは気づきやすいですが、れロが䞊ぶログは「正垞に蚘録されおいる」ずいう倖芳を保ちながら、内偎が完党に空掞です。わたしはそれを52日間、2340行分芋萜ずしおいたした。

コスト集蚈が「静かに嘘を぀いおいた」根本原因は、Stop hook の stdin に usage や model フィヌルドが存圚しないずいう単玔な事実でした。最初のバヌゞョンは「Stopフックの stdin にはトヌクン数が入っおいるはず」ずいう思い蟌みで曞かれおいたのです。

党䜓の流れ

珟圚の cost-tracker.jsv2がどういうパむプラむンで動くかを先に把握しおおくず、埌の解説が入りやすくなりたす。

Claude Code セッション
  │
  │ セッション終了むベント
  ▌
Stop hook 起動
  │
  │ stdin に流れおくるペむロヌド
  │ { session_id, transcript_path, cwd, hook_event_name, ... }
  │   ↑ usage / model は存圚しない ← ここが旧バヌゞョンの穎
  ▌
cost-tracker.js
  │
  │ transcript_path を取り出す
  ▌
セッション JSONL ファむルClaude Codeが曞き続けおいるログ
  │
  │ 行ごずにパヌス
  │ { type: "assistant", message: { model, usage: { ... } } }
  ▌
sumUsageFromTranscript()
  │
  │ 党アシスタントタヌンを走査しお
  │ input_tokens / output_tokens / cache_* を环積加算
  ▌
RATE_TABLE でモデル別レヌトを掛ける
  │
  │ estimatedCostUsd = Σ tokens × rates / 1e6
  ▌
~/.claude/metrics/costs.jsonl に1行远蚘

ポむントは「Stopフックで信頌できる唯䞀のデヌタ゜ヌスはtranscriptファむル」ずいう蚭蚈思想です。stdin ペむロヌドに盎接 usage が来るこずを期埅するのではなく、Claude Code がセッション䞭に曞き続けおいる JSONL ファむルを自分で読む。これが v2 ぞの切り替えの栞心です。

モデル別レヌトテヌブル

ファむルの34〜38行目に実際のレヌトが定矩されおいたす。

// Approximate per-1M-token billing rates (USD).
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate.
const RATE_TABLE = {
  haiku:  { in: 0.80,  out: 4.0,  cacheWrite: 1.00,  cacheRead: 0.08 },
  sonnet: { in: 3.00,  out: 15.0, cacheWrite: 3.75,  cacheRead: 0.30 },
  opus:   { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
};

Haiku・Sonnet・Opus の3段階です。getRates() はモデル名の文字列に 'haiku' 'opus' が含たれるかどうかで振り分け、どちらでもなければ Sonnet ずしお扱いたす。ここはシンプルに文字列マッチで十分で、正芏衚珟を䜿うほどのものではありたせん。

キャッシュはコスト蚈算で意倖ず効いおきたす。cache_creation_input_tokens初回キャッシュ曞き蟌みは通垞入力の1.25倍、cache_read_input_tokens2回目以降の読み出しは0.1倍です。Opus で倧量にキャッシュを䜿えば読み出し単䟡は実質1.50ドル/100䞇トヌクンで、Haiku の通垞入力より安くなるこずもありたす。

transcriptを走査する関数

57〜90行目が今回の修正の栞心です。

function sumUsageFromTranscript(transcriptPath) {
  let content;
  try {
    content = fs.readFileSync(transcriptPath, 'utf8');
  } catch {
    return null;  // 読めなければ null を返しおれロ扱いにする
  }

  let inputTokens = 0;
  let outputTokens = 0;
  let cacheWriteTokens = 0;
  let cacheReadTokens = 0;
  let model = 'unknown';

  for (const line of content.split('\n')) {
    if (!line.trim()) continue;
    let entry;
    try { entry = JSON.parse(line); } catch { continue; }

    if (entry.type !== 'assistant') continue;  // user/tool行はスキップ
    const msg = entry.message;
    if (!msg || !msg.usage) continue;

    const u = msg.usage;
    inputTokens      += toNumber(u.input_tokens);
    outputTokens     += toNumber(u.output_tokens);
    cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
    cacheReadTokens  += toNumber(u.cache_read_input_tokens);

    if (msg.model && msg.model !== 'unknown') model = msg.model;
  }

  return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
}

行ごずに JSON をパヌスしお type === 'assistant' の゚ントリだけを取り出し、message.usage を环積加算したす。モデル名は最埌に芋぀かったものを䜿いたすセッション途䞭でモデルが切り替わっおも、末尟のアシスタントタヌンが正しいモデルを持っおいるずいう想定です。

゚ラヌ凊理は意図的にゆるくなっおいたす。ファむルが読めなければ null、行がパヌスできなければ continue。どちらの堎合も Stop hook 党䜓を倱敗させないようにしおありたす。コスト蚘録が欠けるこずより、Stop hook が゚ラヌを吐いお Claude Code のセッション終了に圱響する方が問題だからです。

stdinの読み取りずフロヌ党䜓

100行目から始たる stdin 凊理の郚分も確認しおおきたす。

const MAX_STDIN = 64 * 1024;
let raw = '';

process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
  if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);
});

process.stdin.on('end', () => {
  try {
    const input = raw.trim() ? JSON.parse(raw) : {};

    const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
      ? input.transcript_path
      : process.env.CLAUDE_TRANSCRIPT_PATH || null;

    // ...
    let usageTotals = null;
    if (transcriptPath && fs.existsSync(transcriptPath)) {
      usageTotals = sumUsageFromTranscript(transcriptPath);
    }
  } catch {
    // Non-blocking — never fail the Stop hook.
  }

  process.stdout.write(raw);  // ECC hook convention: stdin をそのたた stdout に流す
});

stdin から受け取るのは最倧64KBです。Claude Code の Stop hook ペむロヌド自䜓は数癟バむト皋床なのでこれで十分ですが、䞊限を蚭けおおくこずで巚倧な入力が流れ蟌んでも詰たらない蚭蚈にしおありたす。

transcript_path の取り出しは二段構えで、stdin の JSON にあればそれを䜿い、なければ環境倉数 CLAUDE_TRANSCRIPT_PATH にフォヌルバックしたす。どちらもない堎合は null になり、usageTotals は null のたたなのでトヌクン数は党郚 0 ずしお蚘録されたす──これは旧バヌゞョンず同じ結果ですが、意図的なれロです。フォヌルバック先が存圚するため、ファむルパスが取れる通垞ケヌスでは必ず transcript が読たれたす。

最埌の process.stdout.write(raw) は ECC hook の芏玄に埓ったものです。Stop hook は stdin を stdout に流すこずで次のフックにデヌタを枡せるようになっおおり、このスクリプトはコスト集蚈を副䜜甚ずしお行いながら、ペむロヌドをそのたた透過させたす。

1セッション1行の环積蚭蚈

コメントの20〜22行目に曞かれた挙動が地味に重芁です。

// Cumulative behavior: Stop fires per assistant response, not per session.
// Each row therefore represents the cumulative session total up to that point.
// To get per-session cost, take the last row per session_id.

Stop hook はアシスタントタヌンごずに発火したす。぀たり、1セッションで10回アシスタントが応答すれば costs.jsonl に10行远蚘されたす。各行はその時点たでのセッション环積トヌクン数なので、「1セッションのコスト」を取り出したければ同じ session_id の行矀のうち最埌の1行を䜿いたす。日次集蚈なら session_id でグルヌピングしお末尟行を取り、それらを date でたずめる圢になりたす。

この蚭蚈はシンプルですが、途䞭クラッシュした堎合でも最埌に蚘録された行たで䜿えるずいう耐障害性がありたす。セッションが正垞終了しなくおも、その時点たでの环積は残りたす。

実装の詳现

toNumber() ── 「型が合わない」を黙っお吞収する関数

前半で玹介した sumUsageFromTranscript() の䞭で、すべおの加算が toNumber(v) を通っおいたす47〜50行目。

function toNumber(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}

たった3行ですが、これがなければ事故が起きたす。Claude Code の JSONL には、フィヌルドが欠萜しおいる行、null が入っおいる行、皀にキャッシュ関連フィヌルドが文字列ずしお入っおいる行が混圚したす。Number(null) は 0 になりたすが、Number(undefined) も Number(null) も NaN + 正垞倀 = NaN になりたす。䞀箇所でも NaN が混入するず环積合蚈党䜓が NaN になり、最終的なコスト掚蚈が NaN ずしお JSONL に曞かれたす。埌から集蚈スクリプトを曞いたずき、NaN の行が混じっおいるず合蚈が壊れたす。

Number.isFinite() を䜿っおいるのは isNaN() より厳密だからです。isNaN(Infinity) は false を返したす。コスト蚈算に Infinity が入れば合蚈が壊れるので、isFinite でたずめお匟いおいたす。

「防埡的すぎる」ず感じるかもしれたせんが、Stop hook は毎セッション末に無人で走るものです。人間がその郜床確認するわけではないので、想定倖の入力を受け取ったずきに静かにれロずしお扱い、ログ党䜓を壊さないこずの方が重芁です。

session_id の䞉重フォヌルバック

108〜112行目の sessionId の取り出し方を芋おください。

const sessionId =
  sanitizeSessionId(input.session_id) ||
  sanitizeSessionId(process.env.ECC_SESSION_ID) ||
  sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
  'default';

䞉段階のフォヌルバックがありたす。なぜこうなっおいるかずいうず、Stop hook の実行環境がセッションの文脈によっお少しず぀違うからです。

Claude Code が盎接起動したセッションでは input.session_id が正垞に来たす。ですが、launchd から呌ばれるラッパヌスクリプト経由でセッションを起動した堎合や、テスト目的で hook を単䜓実行する堎合は、stdin ペむロヌドが省略されたり別圢匏になるこずがありたす。ECC_SESSION_ID ず CLAUDE_SESSION_ID は、そういったケヌスに備えお環境倉数ずしお枡せるようにしおありたす。

sanitizeSessionId() は session-bridge.js からむンポヌトした関数で、英数字ずハむフン・アンダヌスコア以倖を陀去しおパス等に安党に䜿えるようにサニタむズしたす。Stop hook 経由でファむルに曞かれるセッション ID はそのたた costs.jsonl の行に入るため、改行やスラッシュが混入するず埌の集蚈スクリプトが壊れたす。

最埌の 'default' フォヌルバックは「どこからもセッション ID が取れなかった」ずいう最悪ケヌスです。この堎合、耇数のセッションが党郚 session_id: "default" ずしお蚘録されおしたい、セッション単䜍の集蚈が意味をなさなくなりたす。実際にこのパスに萜ちたら異垞事態なので、埌述するデバッグの際に「default ずいう ID の行が溜たっおいる」ずいう珟象が手がかりになるこずがありたす。

コスト蚈算の粟床ず Math.round() の意図

128〜133行目のコスト蚈算の曞き方はやや特城的です。

const estimatedCostUsd = Math.round((
  (inputTokens      / 1e6) * rates.in +
  (outputTokens     / 1e6) * rates.out +
  (cacheWriteTokens / 1e6) * rates.cacheWrite +
  (cacheReadTokens  / 1e6) * rates.cacheRead
) * 1e6) / 1e6;

* 1e6 しおから Math.round() し、たた / 1e6 で戻しおいたす。これは浮動小数点の䞞め誀差を小数点以䞋6桁で抑えるためです。

JavaScript の Number は IEEE 754 倍粟床浮動小数点なので、0.1 + 0.2 === 0.3 が false になるのず同じ理由で、耇数のレヌトを掛け合わせた环積に䞞め誀差が乗りたす。0.000003000000000000001 のような倀が JSONL に入っおも人間には読みにくく、埌で集蚈するずきも誀差が気になりたす。

小数点以䞋6桁に䞞めおいるのは「USD で1マむクロドル未満の粟床は䞍芁」ずいう刀断です。Haiku のむンプット単䟡は 0.80 ドル/癟䞇トヌクンなので、1䞇トヌクン凊理したずしお0.008ドル、1000トヌクンなら0.0008ドルです。6桁あれば十分なコスト感芚が掎めたす。

appendFile ず JSONL 圢匏の蚭蚈刀断

150行目の曞き出しはシンプルです。

appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);

JSONLJSON Linesを遞んでいる理由は、远蚘が安党にできるからです。JSON 配列ファむルに远蚘しようずするず、末尟の ] を曞き換えなければならないため、曞き蟌み途䞭でプロセスが死んだらファむル党䜓が壊れたす。JSONL は「1行 = 1オブゞェクト」の圢匏なので、远蚘は末尟に1行足すだけです。途䞭でプロセスが萜ちおも既存の行は無事です。

Stop hook は Claude Code の終了シヌケンスの䞭で走るため、曞き蟌み䞭に匷制終了されるリスクがれロではありたせん。そういう状況でもデヌタが最小限の損倱で枈む圢匏を遞んでいたす。

ensureDir(metricsDir) は ~/.claude/metrics/ が存圚しなければ䜜るナヌティリティです。Stop hook を初めお実行するずきにディレクトリが存圚しないず appendFile が倱敗するため、毎回チェックしおいたす。fs.mkdirSync(path, { recursive: true }) のラッパヌで、ディレクトリが存圚する堎合は䜕もしたせん。

process.stdout.write(raw) ── パむプラむンを壊さないための芏玄

最埌の1行156行目の意味を前半で觊れたしたが、もう少し掘り䞋げたす。

process.stdout.write(raw);

ECCEnhanced Claude Codeのフック芏玄では、Stop hook は stdin を stdout にそのたた流すこずが求められおいたす。これは耇数の Stop hook を盎列に繋ぐためです。

Claude Code の settings.json では Stop セクションに耇数のコマンドを䞊べられたす。この堎合、各コマンドが stdin を受け取り、stdout に流すこずで次のコマンドがそのデヌタを受け取れたす。コスト远跡 hook が途䞭でデヌタを飲み蟌んでしたうず、埌続の hook䟋えばセルフ監査 hookがペむロヌドを受け取れなくなりたす。

cost-tracker.js はコスト集蚈を副䜜甚ずしお行い、パむプラむン䞊の透過的な䞭継点ずしお振る舞いたす。このため、゚ラヌが起きおも catch {} でスキップしお最埌に必ず process.stdout.write(raw) たで到達するようになっおいたす。コスト蚘録が欠けるこずより、パむプラむンが途切れるこずの方が圱響が倧きいからです。


私が詰たった話

52日間気づかなかった理由

~/.claude/metrics/costs.jsonl のれロ蚘録に気づいたのは、週次レポヌトを生成しようずしお集蚈スクリプトを走らせたずきです。52日分の JSONL を集蚈するず、2340行すべおの estimated_cost_usd が 0 でした。

最初は「無料枠で動いおいるから圓然では」ず思いたした。Claude Code の MAX 契玄はトヌクン課金ではなく月定額なので、コストが0なのは正しいずも蚀えたす。ですが、input_tokens たで党行 0 なのはおかしい。実際に䜕千回もアシスタントが応答しおいるのに、トヌクン数が蚘録されおいないはずがありたせん。

問題はログが「゚ラヌなく動いおいた」こずです。hook の実行は毎セッション正垞に完了しおいたした。costs.jsonl にはちゃんず行が远蚘され続けおいたした。タむムスタンプも正しい。ただ、数倀フィヌルドが党郚れロでした。

「動いおいるように芋えるが䞭身が空掞」──これが䞀番発芋を遅らせる障害パタヌンです。゚ラヌログがあれば即座に気づけたす。サむレントな誀動䜜は、意識的にデヌタを確認するたで存圚したせん。

v1 の䜕がたずかったか

旧バヌゞョンのコヌドは手元に残っおいたせんが、動䜜から逆算するず構造は明らかです。Stop hook の stdin ペむロヌドを受け取り、そこから盎接 usage や model を取り出そうずしおいたした。

// v1 の想定実際は存圚しないフィヌルド
const { usage, model, session_id } = JSON.parse(raw);
const inputTokens = usage?.input_tokens ?? 0;

Stop hook の stdin に来る実際のペむロヌドは settings.json のコメント9〜10行目に曞いおある通りで、usage も model も含たれおいたせん。

{ session_id, transcript_path, cwd, hook_event_name, ... }

usage?.input_tokens はオプショナルチェヌンなので undefined が返り、?? 0 でれロになりたす。゚ラヌは䞀切発生せず、ただ毎回れロが蚘録されたす。

わたしが芋萜ずしおいた前提は「Stop hook の stdin にはセッション党䜓のコスト情報が集玄されお入っおいる」ずいう思い蟌みでした。実際はそうではありたせん。Stop hook のペむロヌドはセッション管理に必芁な最小限の情報だけで、具䜓的なトヌクン䜿甚量はトランスクリプトファむルを読たなければ取れたせん。Claude Code の公匏ドキュメントにも明瀺的な蚘述がなく、この挙動はトランスクリプトを実際に読んで確かめるたでわかりたせんでした。

transcript_path が取れないずきの沌

v2 に曞き盎した埌、別の詰たりがありたした。ロヌカル環境では正垞に動いおいるのに、launchd 経由で起動したセッションの hook がれロのたた蚘録される行が散発的に混じるのです。

調べるず、transcriptPath && fs.existsSync(transcriptPath) の条件が false になっおいるケヌスがありたした。原因は2぀ありたした。

1぀目は PATH の問題です。 launchd の環境倉数 PATH は /usr/bin:/bin:/usr/sbin:/sbin が基本で、Node.js のある /opt/homebrew/bin や nvm 配䞋が含たれたせん。cost-tracker.js は Node.js スクリプトなので、PATH が正しくないず hook そのものが起動せず、Claude Code が fallback ずしお ペむロヌドをそのたた流す挙動になりたす。この堎合 costs.jsonl には䜕も曞かれないため、行数が少ない日 = hook が動いおいない日、ずいう圢で怜出できたす。plist に PATH を明瀺的に蚭定するこずで解消したした。

2぀目は transcript_path の絶察パス問題です。 Claude Code がセッション JSONL ファむルを曞くパスは ~/.claude/projects/ 以䞋の固定䜍眮ですが、transcript_path フィヌルドに入っおくる文字列が環境によっおは盞察パスになっおいるケヌスがありたした。fs.existsSync は盞察パスをカレントディレクトリ基準で解決するため、launchd から起動した堎合の CWD通垞 /で展開されるず圓然ファむルは芋぀かりたせん。

修正は transcript_path を受け取ったら path.resolve(transcript_path) で絶察化するこずです。ただ、珟圚の v2 コヌドを芋るず path.resolve() は入っおいたせん。実際に問題が再珟した環境でのみ盞察パスが来おおり、通垞の Claude Code セッションでは絶察パスが来るため、リスクは限定的ず刀断しお察応を保留しおいたす。もし「launchd 経由でのみれロ蚘録が散発する」ずいう症状が出たら、たずここを疑うのが近道です。

null 返しが「透明なれロ」になる問題

sumUsageFromTranscript() は読み取りに倱敗するず null を返したす62行目の return null。呌び出し偎の 125行目でこう展開されたす。

const {
  inputTokens = 0,
  outputTokens = 0,
  // ...
} = usageTotals || {};

null が来るず {} に展開され、すべおのデフォルト倀 0 が䜿われたす。結果ずしお、「transcript が読めなかった行」ず「transcript を読んだが本圓にトヌクンがれロだった行ほがありえない」が、costs.jsonl 䞊では区別できたせん。

これが刀明したのは、「ある日の蚘録が劙に少ない」ずいうずきに手動で costs.jsonl を開いお確認したずきです。transcript_path: "" ずいう行が混じっおいたした。transcript_path が空文字のずき、fs.existsSync("") は false を返すため sumUsageFromTranscript() が呌ばれず、usageTotals は null のたた、結果れロ蚘録になりたす。

根本原因は session_id が取れおいおも transcript_path が取れないケヌスがあるずいうこずです。v2 コヌドの 141行目に transcript_path: transcriptPath || '' ずあるので、パスが取れないずきは空文字が蚘録されたす。埌から集蚈スクリプトで transcript_path が空の行を陀倖するか、estimated_cost_usd === 0 && input_tokens === 0 && transcript_path === "" の条件でれロ蚘録を「デヌタなし」ずしお扱うこずで察凊しおいたす。

「环積蚭蚈」を知らずにコストを7倍に蚈算した話

v2 が正しく動き始めた埌、日次コスト集蚈スクリプトを曞いたずきにたた詰たりたした。1日のコストを出そうずしお党行を単玔合蚈するず、実際の感芚より䜕倍も倚い数字が出たした。

コメントの 19〜21 行目前半で匕甚を読み盎しお「あ、环積蚭蚈だ」ず気づきたした。Stop hook はアシスタントタヌンごずに発火するため、1セッションで 10 回応答があれば 10 行が远蚘され、それぞれが「1回目たで」「2回目たで」ずいう环積トヌクン数を持っおいたす。党行を足すず、1タヌン目のトヌクンが10回分、2タヌン目のトヌクンが9回分 ず二重䞉重に蚈䞊されたす。

正しい集蚈の曞き方は、同じ session_id の行をグルヌプ化しお、各グルヌプの最埌の1行だけを取るこずです。

const sessions = {};
for (const line of lines) {
  const row = JSON.parse(line);
  sessions[row.session_id] = row;  // 埌から来る行で䞊曞きされる
}
const dailyCost = Object.values(sessions)
  .filter(r => r.timestamp.startsWith('2026-07-08'))
  .reduce((sum, r) => sum + r.estimated_cost_usd, 0);

このバグを入れた状態で「1日のコスト」を蚈算するず、セッション内の応答回数が倚いほど数字が膚らみたす。10回応答するセッションは1回応答するセッションの玄10倍の「芋かけのコスト」になりたすが、実際には同じモデルを䜿っおいるはずなのに数字に倧きなばら぀きが出る圢で気づきたした。

「デヌタは正しく蚘録されおいるがレポヌトが嘘を぀いおいる」── これも、゚ラヌなく動いおいるように芋える沌のひず぀です。集蚈ロゞックを曞くずきは、サンプルデヌタを手動で確認しおから実装するのが確実です。わたしの堎合、session_id が同じ行を grep で抜き出しお目芖するたで、蚭蚈の意図に気づきたせんでした。

デバッグに䜿えるワンラむナヌ

詰たったずきに手元でよく䜿ったコマンドを残しおおきたす。

れロ蚘録の行数を確認する:

node -e "
const fs = require('fs');
const lines = fs.readFileSync(process.env.HOME + '/.claude/metrics/costs.jsonl','utf8')
  .split('\n').filter(Boolean).map(JSON.parse);
const zeros = lines.filter(r => r.input_tokens === 0);
console.log('total:', lines.length, 'zeros:', zeros.length);
"

transcript_pathが空の行を探す:

node -e "
const fs = require('fs');
const lines = fs.readFileSync(process.env.HOME + '/.claude/metrics/costs.jsonl','utf8')
  .split('\n').filter(Boolean).map(JSON.parse);
lines.filter(r => !r.transcript_path).forEach(r => console.log(r.timestamp, r.session_id));
"

v2 に切り替えた埌に䞊蚘を実行したずき、1行目のコマンドで zeros: 0 が出ればすべおの蚘録にトヌクン数が入っおいたす。れロが残っおいる堎合は、transcript_path の取埗に問題があるか、フォヌルバックパスに萜ちおいるケヌスです。

52日分のれロ蚘録を捚おるしかなかったのは痛かったですが、v2 に切り替えおからは毎セッション正確なトヌクン数が積たれおいたす。自埋環境のコスト感芚を持おるようになっお初めお、「どの䜜業が消費が倚く、どこを効率化すべきか」ずいう議論ができるようになりたした。れロが䞊ぶログは「動いおいる蚌拠」ではなく「䜕も蚘録しおいない蚌拠」です──そのこずを、2340行かけお孊びたした。

぀たずきポむント

前段で玹介した「52日れロ蚘録」「cumulative蚭蚈の誀集蚈」「launchd PATH欠萜」のほかにも、Stop hookを実運甚しおいるず詰たる堎所はいく぀かありたす。ここでは網矅的に列挙したす。

① モデル名が将来マッチしなくなる

getRates() は文字列に 'haiku' 'opus' が含たれるかどうかで振り分け、それ以倖は Sonnet ずしお扱いたす。珟行のモデル呜名芏則claude-haiku-4-5-20251001 などではこれで十分ですが、将来 Anthropic がファミリヌ名を倉曎したずきたずえば haiku 系列に別称が぀く、など、マッチが倖れおすべおが Sonnet レヌトになりたす。゚ラヌは出ず、レヌトだけ静かにずれたす。定期的にコヌドず最新モデル名を照合する運甚か、マッチ倱敗時にログを残す分岐を入れおおくのが安党です。

② Stop hookが発火しないケヌス

Claude Code がプロセスを SIGKILL で匷制終了された堎合システムOOMやバッテリヌ切れなど、Stop hookは実行されたせん。正垞な /exit ずタむムアりトでは発火したすが、匷制終了はセッション終了シヌケンスをスキップするため、そのセッション分の蚘録が䞞ごず欠萜したす。月間コスト集蚈の誀差を気にするなら「蚘録がない = れロではなくデヌタ欠損」ずいう解釈を集蚈スクリプト偎に入れおおく必芁がありたす。具䜓的には、セッション数ず行数の比率を監芖しお「期埅行数より著しく少ない日」をアラヌトの察象にする方法がありたす。

③ 長倧トランスクリプトの同期読み蟌みブロッキング

sumUsageFromTranscript() は fs.readFileSync() を䜿っおいたす。Claude Code の長いセッション数十タヌン以䞊の耇雑な実装タスクでは、トランスクリプト JSONL が数十MB になるこずがありたす。readFileSync は同期呌び出しなので、その間 Node.js のむベントルヌプが止たりたす。Stop hook 自䜓は単発実行なのでむベントルヌプが止たっおも問題はありたせんが、読み蟌みに2〜3秒かかるず Claude Code のセッション終了レスポンスが遅れる圢ずしお䜓感されたす。実枬で問題になった堎合は、ストリヌムで先頭から行単䜍に読む実装に切り替えるこずで、ファむル党䜓を䞀床にメモリに茉せるのを避けられたす。

④ hook chain でのstdin競合

Stop セクションに耇数の hook を登録した堎合、各 hook は盎列に実行され、前の hook の stdout が次の hook の stdin になりたす。cost-tracker.js の最埌の process.stdout.write(raw) は、この芏玄を守るためのものです。もし chain の途䞭に process.exit(0) を呌ぶ hook が割り蟌むか、stdout に raw 以倖のデヌタを流す hook が存圚するず、埌続の hook がペむロヌドを正しく受け取れなくなりたす。chain の各 hook を曞くずきは「stdin を受け取り、stdout にそのたた流す」圢を培底する必芁がありたす。cost-tracker に限らず、Stop hook 党般に蚀える芏埋です。

â‘€ costs.jsonl の肥倧化

Stop hook はアシスタントタヌンごずに発火し、1行远蚘したす。1日10セッションで平均20タヌンなら、1日200行が積たれたす。1幎継続するず7䞇行を超えたす。costs.jsonl 自䜓は各行が数癟バむト皋床なのでストレヌゞはさほど問題になりたせんが、埌から曞く集蚈スクリプトが党行を読む構造だず、時間が経぀ほどスキャン時間が増えたす。定期的に叀い行を幎次ファむルにアヌカむブする運甚か、集蚈ク゚リを「盎近N日だけスキャン」する圢にしおおくず長期運甚が楜です。

⑥ タむムゟヌン問題

new Date().toISOString() が曞く timestamp は UTC です。日次集蚈を JST 基準でやりたい堎合、2026-07-09T00:30:00.000Z ずいう行は JST では7月9日ですが、UTC では7月8日のカりントになりたす。日をたたぐ深倜のセッションで集蚈日付がずれる珟象ずしお珟れたす。修正は timestamp を取り出すずきに +9h しお JST の日付文字列に倉換するこずですが、JSONL に入っおいる倀は UTC のたたにしおおき、集蚈時に倉換する方が䞀貫性を保おたす。JSONL の倀を JST で曞き盎すず、埌から UTC ベヌスに戻したくなったずきに元デヌタが倱われたす。

⑩ 曞き蟌み䞭トランスクリプトの読み蟌みタむミング

Claude Code がアシスタントタヌンを曞いおいる最䞭に Stop hook が走る゚ッゞケヌスがありたす非垞に短いタヌンの応答埌など。このずき、トランスクリプト JSONL の末尟行が䞍完党な JSON で終わっおいる可胜性がありたす。sumUsageFromTranscript() のパヌス郚分は try { entry = JSON.parse(line); } catch { continue; } で行単䜍に凊理するので、壊れた末尟行は continue でスキップされたす。結果ずしお、その行分のトヌクンが集蚈されない盎前の正垞行たで集蚈されるずいう挙動になりたす。実際にこれが問題になるこずはほがありたせんが、「コストが埮劙に少なく蚘録される」こずの䞀因にはなりえたす。

⑧ session_id重耇ず 'default' フォヌルバック

前段で觊れた通り、session_id がどこからも取れない堎合は 'default' ずいう固定倀になりたす。もし耇数の Claude Code むンスタンスが同時に動いおいおlaunchd ゞョブが䞊走するケヌスなど、それぞれが 'default' ずしお蚘録されるず、cumulative 蚭蚈の「session_id でグルヌプ化しお最埌の1行を䜿う」集蚈が厩れたす。'default' でグルヌプされた行矀のうち最埌の1行だけが䜿われ、それが最も小さいセッション分だったり最も倧きい分だったりず、結果が䞍定になりたす。session_id: "default" の行を定期的に grep で確認しお、発生頻床を把握しおおくこずをおすすめしたす。

⑹ require() ず Node.js バヌゞョンの䞍䞀臎

cost-tracker.js は CommonJS'use strict'; const fs = require('fs');で曞かれおいたす。nvm で Node.js のバヌゞョンを䞊げた盎埌や、䟝存ラむブラリが ESM 専甚に移行した堎合、require() で ESM モゞュヌルをロヌドしようずしお ERR_REQUIRE_ESM ゚ラヌが出たす。この゚ラヌは hook 実行時に Node.js が stderr に吐きたすが、Claude Code の通垞出力には珟れたせん。hook が静かに倱敗しお costs.jsonl に䜕も曞かれない圢になるため、「れロ蚘録」ず区別が぀きたせん。node --version ず䟝存関係の ESM 察応状況を Node.js アップグレヌド時に必ず確認する必芁がありたす。


ベストプラクティス

52日間の倱敗ず、そこからの v2 ぞの曞き盎し、その埌の现かい詰たりを経お、Stop hook でコスト远跡をする際のベストプラクティスずしおたずめられるものは次の通りです。

① Stopフックで信頌できるデヌタ゜ヌスはtranscriptファむルだけ

最も重芁な教蚓です。stdin ペむロヌドにはトヌクン䜿甚量は入っおいたせん。transcript_path を受け取り、セッション JSONL を自分で読む。この蚭蚈に気づくたでの52日が、この蚘事党䜓の背景にある事実です。䜕かを蚈枬しようずするなら、たずそのデヌタがどこにあるかを確認する。「あるはずだ」ずいう前提でコヌドを曞くず、サむレントに嘘を぀くツヌルができあがりたす。

② Stop hookは絶察に倱敗させない

コスト蚘録が欠けるこずよりも、Stop hook が゚ラヌを吐いお Claude Code のセッション終了シヌケンスに圱響する方が問題です。メむン凊理党䜓を try { ... } catch { } で包み、゚ラヌが起きおも process.stdout.write(raw) たで必ず到達する蚭蚈にする。cost-tracker.js の151〜156行がそのパタヌンです。「ログが欠ける日があっおもいい、セッションが壊れおはいけない」ずいう優先順䜍の刀断です。

③ stdin → stdout の透過パタヌンを守る

chain 内の党 hook が stdin を受けお stdout に流す ずいう芏玄を守れば、任意の数の hook を盎列に繋げたす。この芏玄を砎るず、埌続の hook が静かに動かなくなりたす。hook を曞くずきは機胜実装を副䜜甚に留め、パむプラむンぞの圱響を最小化する発想が倧切です。

④ 型倉換は toNumber() で防埡的に䞀元化する

倖郚デヌタトランスクリプト JSONLから読んだ倀を盎接算術挔算に䜿わない。NaN が䞀箇所でも环積合蚈に混入するず、その集蚈党䜓が NaN になりたす。Number.isFinite() を䜿っお NaN ず Infinity を䞡方匟く関数を䞀個眮いおおく。3行で曞けお、数週間埌の自分を助けたす。

â‘€ 远蚘ファむルはJSONLで曞く

JSON 配列ではなく JSON Lines1行1オブゞェクトを遞ぶ理由はシンプルです。JSON 配列ぞの远蚘はファむル末尟を曞き換えるため、途䞭でプロセスが萜ちるずファむル党䜓が壊れたす。JSONL なら远蚘は末尟に1行足すだけ。Stop hook は Claude Code の終了シヌケンス䞭で動くため、曞き蟌み䞭に匷制終了されるリスクがれロではありたせん。JSONL はその状況でも最小限の損倱で枈む圢匏です。

⑥ 集蚈はsession_id最終行を䜿う

Stop hook はタヌンごずに発火するため、1セッションが耇数行になりたす。党行を単玔合蚈するずコストが䜕倍にも膚らみたす。集蚈は session_id でグルヌプ化し、各グルヌプの最埌の1行だけを䜿う。この蚭蚈はコメントcost-tracker.js の19〜21行目に明蚘されおいたすが、コヌドを読たずに集蚈スクリプトを曞くず必ず螏む穎です。

const sessions = {};
for (const line of lines) {
  const row = JSON.parse(line);
  sessions[row.session_id] = row;  // 埌の行で䞊曞き → 自動的に最終行が残る
}

⑩ launchd plistにPATHを明瀺する

launchd の実行環境は /usr/bin:/bin:/usr/sbin:/sbin が基本 PATH で、Node.js が /opt/homebrew/bin にある堎合も nvm 配䞋にある堎合も、PATH が通っおいなければ hook が起動したせん。静かに倱敗しお costs.jsonl に䜕も曞かれない圢になりたす。plist に <key>EnvironmentVariables</key> で PATH を明瀺し、Node.js の実パスを含めおおく。この察凊は launchd で動く党スクリプトに共通の基本です。

⑧ metricsディレクトリを毎回 ensureDir で䜜る

~/.claude/metrics/ が存圚しない状態で appendFile を呌ぶず゚ラヌになりたす。hook を初めおセットアップした盎埌や、ディレクトリを誀っお消した堎合に発生したす。fs.mkdirSync(path, { recursive: true }) のラッパヌを毎回呌ぶ。ディレクトリが存圚すれば䜕もしないので、オヌバヌヘッドはほがれロです。「あっお圓然」のディレクトリを前提にしたコヌドは、環境が倉わるず静かに壊れたす。

⑹ stdin に䞊限を蚭ける

const MAX_STDIN = 64 * 1024;
if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);

Stop hook のペむロヌド自䜓は数癟バむトですが、䞊限なしに読むず䜕らかの異垞で巚倧な入力が流れ蟌んだずき Node.js のヒヌプを圧迫したす。64KB を䞊限にするこずで、通垞ケヌスには圱響なく、異垞ケヌスで詰たらない蚭蚈になりたす。

⑩ session_id はサニタむズしおから䜿う

セッション ID をそのたたファむルに曞くず、将来そのフィヌルドでファむル名を䜜ったりパスに組み蟌んだりするずきにむンゞェクションリスクが生じたす。英数字・ハむフン・アンダヌスコア以倖を陀去するサニタむズを䞀枚かたしおおく。sanitizeSessionId() を session-bridge.js に切り出しおいるのは、同じロゞックが耇数 hook で䜿われるためです。共通ロゞックは lib/ に眮いお import する。手抜きで重耇コピヌしないこずが、埌の保守コストを䞋げたす。

⑪ 盞察パスはpath.resolve()で絶察化する

fs.existsSync(transcriptPath) は盞察パスをカレントディレクトリ基準で解決したす。launchd から起動した堎合の CWD は通垞 / なので、盞察パスが来るず意図しない堎所を参照したす。transcript_path を受け取った盎埌に path.resolve(transcriptPath) で絶察化する䞀行を入れおおくず、起動元に䟝存しない動䜜になりたす。珟行の v2 ではこの凊理が省かれおおり、通垞の Claude Code セッションでは問題ないですが、launchd 経由での散発的なれロ蚘録が気になる堎合は最初に疑う箇所です。

⑫ タむムゟヌンはUTCで蚘録・倉換は集蚈時に行う

JSONL の timestamp は new Date().toISOString()UTCで蚘録し、JST ぞの倉換は集蚈スクリプト偎でやる。元デヌタを特定タむムゟヌンで曞き盎すず「元に戻せない」状況が生たれたす。UTC で保存しおおけば埌から任意のタむムゟヌンに倉換できたす。日次集蚈を JST 基準でやるなら new Date(timestamp).toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' }) で倉換する集蚈関数を䞀本曞けば枈みたす。

⑬ 「れロ蚘録」ず「デヌタなし」を区別する

transcript_path が空文字の行ず、transcript が読めお本圓にトヌクンれロだった行は、珟行の JSONL では区別できたせん。集蚈スクリプトで「estimated_cost_usd === 0 && input_tokens === 0 && transcript_path === ''」の組み合わせを「デヌタなし」ずしお陀倖するず、れロが混じった日の集蚈粟床が䞊がりたす。サむレントなれロを信頌するより、「これはデヌタがない行だ」ず明瀺的にラベルできる構造にする方が、長期運甚で正確な感芚を保おたす。


たずめ

Stop hook に usage や model が来るず思っお曞いたコヌドが、52日間・2340行にわたっお党行れロを吐き続けた──これは「動いおいるように芋える」ずいう状態の怖さを䜓感した経隓でした。゚ラヌが出おいれば翌日には気づけたはずです。サむレントに嘘を぀くバグは、意識的にデヌタを確認するたで存圚が芋えたせん。

v2 ぞの修正の栞心は䞀行です。「stdin から usage を読む」のをやめお、「stdin から transcript_path を読み、そのファむルを自分で読む」に倉えた。それだけで52日分の空掞が埋たりたした。

Stopフックで信頌できる唯䞀のデヌタ゜ヌスはtranscriptファむル──この教蚓は Claude Code に限らず、「フックやむベントのペむロヌドに欲しいデヌタが入っおいるはずだ」ずいう前提で自動化を曞くすべおの堎面に通じたす。ペむロヌドの仕様を確認せずに曞き始めるず、自分でデヌタを読みに行かなければいけない堎合に静かに壊れるコヌドができあがりたす。

自埋環境の粟床は、蚈枬の粟床に䟝存したす。コストが正しく蚘録されるたで、「どのセッションが重く、どこを削れるか」ずいう議論は成立したせん。コスト远跡は環境構築の最埌ではなく、最初に確かめるべき基盀です。


仕組みの党䜓像・月120䞇の内蚳・30日手順は有料noteにたずめおいたす。
📕 Claude Code自埋環境で、実際どう皌ぐか ― 仕組み・実䟋・始め方・サポヌト


Lily@bokuwalily― 個人開発者。Claude Code で自動化基盀を組みながら、iOSアプリやWebサヌビスを量産しおいたす

  • 制䜜物・蚘事は bokuwalily.com にたずめおいたす🖥
  • AIで「寝おおも回る仕組み」を䜜っお月120䞇にした話は noteの有料蚘事 に💰
  • OSS: github.com/bokuwalily 🐙
  • 最新情報・お問い合わせは X @bokuwalily ぞ🌍

皆さんの ❀ やシェアが励みになりたす

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?